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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ npm run check
npm run pack:check
```

To reproduce the length-delimited SHA-256 used by external evidence to identify the packaged JavaScript runtime
inputs (`package.json`, `bin/firstdraft.js`, and every `.js` file under `src/`), run from the repository root:

```sh
node scripts/runtime-digest.js
```

## Start a Foundation Plan

From the project that the Plan describes:
Expand Down Expand Up @@ -195,8 +202,10 @@ firstdraft compilation download 01900000-0000-7000-8000-000000000001 --output ..

The command validates the UUID and output path before network access, makes one status `GET`, requires
`succeeded`, and makes one artifact `GET`. It never starts work or polls. Historical artifact validation uses
the retained `compilation.head_source_sha256`, not the current local Plan or ETag; both artifact
`head_source_sha256` and `foundation_plan.sha256` must equal that retained Head.
the retained `compilation.head_source_sha256`, not the current local Plan or ETag, to pin the artifact's exact
`head_source_sha256`. The artifact's canonical `foundation_plan.sha256` may differ because it identifies the
normalized Compiler input. It is validated as a SHA-256 digest inside the exact artifact bytes authenticated by
the status response's `artifact.sha256`; it is not equated to the submitted Head digest.

Before materialization, the CLI verifies the artifact media type, declared and actual byte sizes, strong digest
ETag, exact-byte SHA-256, canonical UTF-8 JSON envelope, provenance, metadata-only manifest digest, portable paths,
Expand Down
50 changes: 50 additions & 0 deletions scripts/runtime-digest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const repository = fileURLToPath(new URL("..", import.meta.url));
const inputs = listJavaScriptFiles(path.join(repository, "src"))
.concat([
path.join(repository, "bin", "firstdraft.js"),
path.join(repository, "package.json"),
])
.map((file) => ({
file,
relativePath: path.relative(repository, file).split(path.sep).join("/"),
}))
.sort((left, right) =>
left.relativePath < right.relativePath
? -1
: left.relativePath > right.relativePath
? 1
: 0,
);
const digest = createHash("sha256");

for (const { file, relativePath } of inputs) {
const source = readFileSync(file);
const relativePathLength = Buffer.alloc(4);
relativePathLength.writeUInt32BE(Buffer.byteLength(relativePath));
const sourceLength = Buffer.alloc(8);
sourceLength.writeBigUInt64BE(BigInt(source.byteLength));
digest.update(relativePathLength);
digest.update(relativePath);
digest.update(sourceLength);
digest.update(source);
}

process.stdout.write(`${digest.digest("hex")}\n`);

/** @param {string} directory @returns {string[]} */
function listJavaScriptFiles(directory) {
return readdirSync(directory, { withFileTypes: true })
.flatMap((entry) => {
const entryPath = path.join(directory, entry.name);

if (entry.isDirectory()) return listJavaScriptFiles(entryPath);

return entry.isFile() && entry.name.endsWith(".js") ? [entryPath] : [];
})
.sort();
}
5 changes: 4 additions & 1 deletion scripts/smoke-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,9 @@ async function exercisePackedCompilation(projectDirectory) {
path.join(projectDirectory, ".firstdraft", "foundation-plan.json"),
);
const headSha256 = sha256(plan);
const foundationPlanSha256 = sha256(
Buffer.from("canonical Foundation Plan snapshot"),
);
const statusPath = `/v1/projects/${projectId}/compilations/${compilationId}`;
const artifactPath = `${statusPath}/artifact`;
const compilerRelease = "foundation-plan-rails/compiler-scalar-2026-08";
Expand Down Expand Up @@ -360,7 +363,7 @@ async function exercisePackedCompilation(projectDirectory) {
head_source_sha256: headSha256,
foundation_plan: {
format: "firstdraft.foundation-plan.sketch/0.19",
sha256: headSha256,
sha256: foundationPlanSha256,
},
analysis: {
id: analysisId,
Expand Down
21 changes: 19 additions & 2 deletions src/compilation-artifact.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ export class CompilationArtifactInvalidError extends Error {}
export class CompilationMaterializationError extends Error {}
export class CompilationOutputPathError extends Error {}

/**
* @typedef {object} ValidatedCompilationProvenance
* @property {string} compilation_id
* @property {string} project_id
* @property {number} graph_version
* @property {string} head_source_sha256
* @property {{format: string, sha256: string}} foundation_plan
* @property {{id: string, release: string}} analysis
* @property {string} compiler_release
* @property {{id: string, profile: string}} target
* @property {{repository: string, revision: string, sha256: string}} core
*/

/**
* @typedef {object} CompilationArtifactExpectations
* @property {string} projectId
Expand All @@ -88,7 +101,7 @@ export class CompilationOutputPathError extends Error {}
/**
* @typedef {object} ValidatedCompilationArtifact
* @property {string} manifest_sha256
* @property {Record<string, unknown>} provenance
* @property {ValidatedCompilationProvenance} provenance
* @property {ValidatedArtifactFile[]} files
*/

Expand Down Expand Up @@ -236,8 +249,11 @@ export function materializeCompilationArtifact(artifact, target) {
/**
* @param {unknown} value
* @param {CompilationArtifactExpectations} expected
* @returns {ValidatedCompilationProvenance}
*/
function parseProvenance(value, expected) {
// The outer artifact digest authenticates the canonical Plan digest, which
// need not equal the digest of the exact submitted Head bytes.
if (
!hasExactKeys(value, PROVENANCE_KEYS) ||
value.compilation_id !== expected.compilationId ||
Expand All @@ -246,7 +262,8 @@ function parseProvenance(value, expected) {
value.head_source_sha256 !== expected.headSourceSha256 ||
!hasExactKeys(value.foundation_plan, FOUNDATION_PLAN_KEYS) ||
value.foundation_plan.format !== FOUNDATION_PLAN_FORMAT ||
value.foundation_plan.sha256 !== expected.headSourceSha256 ||
typeof value.foundation_plan.sha256 !== "string" ||
!SHA256_PATTERN.test(value.foundation_plan.sha256) ||
!hasExactKeys(value.analysis, ANALYSIS_KEYS) ||
value.analysis.id !== expected.analysisRunId ||
!isRelease(value.analysis.release) ||
Expand Down
18 changes: 14 additions & 4 deletions test/compilation-artifact.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const COMPILATION_ID = "01900000-0000-7000-8000-000000000802";
const ANALYSIS_ID = "01900000-0000-7000-8000-000000000803";
const SUBJECT_ID = "01900000-0000-7000-8000-000000000804";
const HEAD_SHA256 = "1".repeat(64);
const FOUNDATION_PLAN_SHA256 = "5".repeat(64);
const COMPILER_RELEASE = "foundation-plan-rails/compiler-scalar-2026-08";
const TARGET = { id: "rails", profile: "rails-sketch/2026-08" };
const EXPECTED = {
Expand All @@ -44,6 +45,15 @@ test("parses canonical binary-safe artifact bytes and materializes an exact tree
assert.equal(MAX_ARTIFACT_BYTES, 16 * 1024 * 1024);
const fixture = artifactFixture();
const artifact = parseCompilationArtifact(fixture.source, EXPECTED);
assert.equal(artifact.provenance.head_source_sha256, HEAD_SHA256);
assert.equal(
artifact.provenance.foundation_plan.sha256,
FOUNDATION_PLAN_SHA256,
);
assert.notEqual(
artifact.provenance.head_source_sha256,
artifact.provenance.foundation_plan.sha256,
);
const parent = temporaryDirectory(context);
const target = resolveOutputTarget({
cwd: parent,
Expand Down Expand Up @@ -134,7 +144,7 @@ test("rejects noncanonical, duplicate-key, additive, and non-UTF-8 envelopes", (
}
});

test("pins every available provenance identity", () => {
test("pins external provenance identities and validates nested metadata", () => {
/** @type {[string, string | number][]} */
const cases = [
["compilation_id", "01900000-0000-7000-8000-000000000899"],
Expand All @@ -159,15 +169,15 @@ test("pins every available provenance identity", () => {
provenance: {
foundation_plan: {
format: "firstdraft.foundation-plan.sketch/0.18",
sha256: HEAD_SHA256,
sha256: FOUNDATION_PLAN_SHA256,
},
},
}),
artifactFixture({
provenance: {
foundation_plan: {
format: FOUNDATION_PLAN_FORMAT,
sha256: "2".repeat(64),
sha256: "not-a-sha256",
},
},
}),
Expand Down Expand Up @@ -364,7 +374,7 @@ function artifactFixture(changes = {}) {
head_source_sha256: HEAD_SHA256,
foundation_plan: {
format: FOUNDATION_PLAN_FORMAT,
sha256: HEAD_SHA256,
sha256: FOUNDATION_PLAN_SHA256,
},
analysis: {
id: ANALYSIS_ID,
Expand Down
11 changes: 6 additions & 5 deletions test/compilation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const COMPILATION_ID = "01900000-0000-7000-8000-000000003002";
const ANALYSIS_ID = "01900000-0000-7000-8000-000000003004";
const API_TOKEN = `fd_${"b".repeat(43)}`;
const RETAINED_HEAD = "1".repeat(64);
const CANONICAL_PLAN = "4".repeat(64);
const LOCAL_HEAD = "9".repeat(64);
const CREATED_AT = "2026-08-04T12:00:00.000000Z";
const STARTED_AT = "2026-08-04T12:00:01.000000Z";
Expand Down Expand Up @@ -142,7 +143,7 @@ test("compilation status has a bounded wait and validates exact response shapes"
}
});

test("compilation download reads retained status and artifact once without starting work", async (context) => {
test("compilation download distinguishes Head and Plan provenance without starting work", async (context) => {
const cwd = remoteDirectory(context);
const fixture = artifactFixture();
const status = compilationBody("succeeded", { artifact: fixture });
Expand Down Expand Up @@ -198,7 +199,7 @@ test("download requires succeeded status and validates historical Head provenanc
assert.equal(queuedCalls.length, 1);
assert.equal(existsSync(queuedOutput), false);

const mismatched = artifactFixture({ foundationPlanSha256: LOCAL_HEAD });
const mismatched = artifactFixture({ headSourceSha256: LOCAL_HEAD });
const mismatchOutput = path.join(cwd, "mismatch-output");
const mismatch = await invoke(
["compilation", "download", COMPILATION_ID, "--output", mismatchOutput],
Expand Down Expand Up @@ -381,7 +382,7 @@ function compilationBody(status, changes = {}) {
};
}

/** @param {{foundationPlanSha256?: string}} [changes] */
/** @param {{headSourceSha256?: string}} [changes] */
function artifactFixture(changes = {}) {
const contents = Buffer.from("Movie Catalog\n");
const file = {
Expand Down Expand Up @@ -409,10 +410,10 @@ function artifactFixture(changes = {}) {
compilation_id: COMPILATION_ID,
project_id: PROJECT_ID,
graph_version: 7,
head_source_sha256: RETAINED_HEAD,
head_source_sha256: changes.headSourceSha256 ?? RETAINED_HEAD,
foundation_plan: {
format: FOUNDATION_PLAN_FORMAT,
sha256: changes.foundationPlanSha256 ?? RETAINED_HEAD,
sha256: CANONICAL_PLAN,
},
analysis: {
id: ANALYSIS_ID,
Expand Down