Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions loopx/control_plane/runtime/checkpoint_artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
import {createHash} from "node:crypto";
import {createReadStream} from "node:fs";
import {copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rm} from "node:fs/promises";
import {chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, rm} from "node:fs/promises";
import {dirname, isAbsolute, join, resolve, sep} from "node:path";
import type {JsonObject} from "../effect_program.ts";
import {jsonObject, requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts";
Expand Down Expand Up @@ -114,6 +114,28 @@ async function sqliteSidecar(path: string, suffixes: string[]): Promise<boolean>
try {return (await lstat(database)).isFile() && await sqliteFile(database);}
catch (error) {if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error;}
}
async function syncCopiedFile(path: string): Promise<void> {
const handle = await open(path, "r+");
try {await handle.sync();} finally {await handle.close();}
}
/** Flush a freshly copied checkpoint member through an updatable handle.
* copyFile preserves a read-only participant's mode, and Windows
* FlushFileBuffers rejects read-only handles, so a read-only copy grants owner
* access only to this staged copy for the durability sync and restores its
* mode before the caller witnesses or publishes it. POSIX syncs a read-only
* handle directly, but the update path covers both read-only cases.
*/
async function flushCopiedFile(path: string): Promise<void> {
try {await syncCopiedFile(path);}
catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EACCES" && code !== "EPERM") throw error;
const mode = (await lstat(path)).mode & 0o777;
await chmod(path, mode | 0o600);
try {await syncCopiedFile(path);}
finally {await chmod(path, mode);}
}
}
async function copyComponent(source: string, destination: string, snapshotDatabases = true): Promise<void> {
const stat = await lstat(source);
if (stat.isSymbolicLink()) throw new Error("checkpoint source became a symlink");
Expand All @@ -135,10 +157,7 @@ async function copyComponent(source: string, destination: string, snapshotDataba
// SQLite's existing physical snapshot owner seals committed WAL contents.
if (snapshotDatabases && await sqliteFile(source)) await snapshotSqliteDatabase(source, destination);
else await copyFile(source, destination);
// copyFile preserves a read-only participant's mode. Syncing its bytes
// needs no write access; r+ fails for an ordinary non-root controller.
const handle = await open(destination, "r");
try {await handle.sync();} finally {await handle.close();}
await flushCopiedFile(destination);
} else throw new Error("checkpoint source contains an unsupported file type");
}
function manifestBody(raw: unknown): JsonObject {
Expand Down
26 changes: 23 additions & 3 deletions tests/control_plane_ts/checkpoint_artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile} from "node:fs/promises";
import {chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {checkpointArtifacts} from "../../loopx/control_plane/runtime/checkpoint_artifacts.ts";
Expand Down Expand Up @@ -39,6 +39,22 @@ test("capture, verify and inert materialization preserve executable code and emp
expected_sha256: captured.sha256, destination: join(root, "restored")}), /already exists/);
});

test("a read-only participant's copied mode survives the durability flush and materialization", async t => {
const {root, request} = await fixture(t);
await chmod(join(root, "code/solution.sh"), 0o444);
const components = request.components;
const probe = await checkpointArtifacts({action: "probe", components});
const captured = await checkpointArtifacts({...request, components, expected_source_sha256: probe.source_sha256});
assert.equal(captured.integrity_verified, true);
const copied = join(root, "captured/components/code/solution.sh");
assert.equal((await stat(copied)).mode & 0o777, 0o444);
await checkpointArtifacts({action: "materialize", root: request.destination, expected_sha256: captured.sha256,
destination: join(root, "restored")});
const restored = join(root, "restored/components/code/solution.sh");
assert.equal(await readFile(restored, "utf8"), "#!/bin/sh\nexit 0\n");
assert.equal((await stat(restored)).mode & 0o777, 0o444);
});

test("changed source, duplicate identities and overlapping sources publish no unit", async t => {
const {root, request} = await fixture(t);
await writeFile(join(root, "conversation"), "new native cursor");
Expand Down Expand Up @@ -90,8 +106,12 @@ test("symlink sources, nested links and redirected destinations reject without p
const {root, request} = await fixture(t);
await symlink(join(root, "code"), join(root, "alias"), process.platform === "win32" ? "junction" : "dir");
await assert.rejects(checkpointArtifacts({action: "probe", components: [{id: "code", source_path: join(root, "alias")}]}), /symlink/);
await symlink(join(root, "conversation"), join(root, "code/link"));
await assert.rejects(checkpointArtifacts(request), /symlink/);
// File symlink creation needs Developer Mode or admin on Windows; the
// junction cases still cover redirected sources and destinations.
if (process.platform !== "win32") {
await symlink(join(root, "conversation"), join(root, "code/link"));
await assert.rejects(checkpointArtifacts(request), /symlink/);
}
await assert.rejects(checkpointArtifacts({...request, destination: join(root, "alias/capture")}), /symlink/);
});

Expand Down
Loading