fix: certify generated symlink state
This commit is contained in:
@@ -207,6 +207,10 @@ pnpm install
|
|||||||
|
|
||||||
# Verify dependencies and generated state before running source-quality gates.
|
# Verify dependencies and generated state before running source-quality gates.
|
||||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||||
|
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||||
|
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||||
|
# accidental/independent drift, not a same-UID actor that can rewrite both records
|
||||||
|
# consistently (CWE-345); an external trust anchor is required for that boundary.
|
||||||
pnpm preflight
|
pnpm preflight
|
||||||
|
|
||||||
# Optional local queue service only. This does not start PostgreSQL.
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
|
|||||||
@@ -40,6 +40,14 @@
|
|||||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||||
|
|
||||||
|
## Review remediation — restated AC2
|
||||||
|
|
||||||
|
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||||
|
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||||
|
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||||
|
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||||
|
- Threat-model ruling: the manifest detects accidental, independent, and stale mutation only. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. The residual belongs to the planned choke-point executor / PostgreSQL spine where verification can occur outside worktree authority.
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
|
|
||||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||||
|
|||||||
+39
-9
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { sourceFingerprint } from './preflight.mjs';
|
import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';
|
||||||
|
|
||||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
@@ -26,6 +26,18 @@ function run(command, args, options) {
|
|||||||
|
|
||||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
|
||||||
|
async function requireRealDirectory(target, { allowMissing = false } = {}) {
|
||||||
|
try {
|
||||||
|
const stats = await lstat(target);
|
||||||
|
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||||
|
throw new Error(`${target} must be a real directory, not a symbolic link.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (allowMissing && error.code === 'ENOENT') return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function acquireBuildLock(root) {
|
async function acquireBuildLock(root) {
|
||||||
const workRoot = path.join(root, '.mosaic-test-work');
|
const workRoot = path.join(root, '.mosaic-test-work');
|
||||||
const lock = path.join(workRoot, 'web-build.lock');
|
const lock = path.join(workRoot, 'web-build.lock');
|
||||||
@@ -89,12 +101,19 @@ export async function buildWeb({
|
|||||||
try {
|
try {
|
||||||
const webDir = path.join(root, 'apps', 'web');
|
const webDir = path.join(root, 'apps', 'web');
|
||||||
const nextDir = path.join(webDir, '.next');
|
const nextDir = path.join(webDir, '.next');
|
||||||
const marker = path.join(nextDir, '.mosaic-source-hash');
|
const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
|
||||||
const temporary = `${marker}.${randomUUID()}.tmp`;
|
const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
|
||||||
|
const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
|
||||||
|
const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
|
||||||
const before = await fingerprint(root);
|
const before = await fingerprint(root);
|
||||||
|
|
||||||
await rm(marker, { force: true });
|
await requireRealDirectory(nextDir, { allowMissing: true });
|
||||||
|
await Promise.all([
|
||||||
|
rm(certificationMarker, { force: true }),
|
||||||
|
rm(symlinkManifest, { force: true }),
|
||||||
|
]);
|
||||||
await runBuild(webDir);
|
await runBuild(webDir);
|
||||||
|
await requireRealDirectory(nextDir);
|
||||||
|
|
||||||
const after = await fingerprint(root);
|
const after = await fingerprint(root);
|
||||||
if (after !== before) {
|
if (after !== before) {
|
||||||
@@ -103,9 +122,20 @@ export async function buildWeb({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(nextDir, { recursive: true });
|
const manifestContents = await generatedSymlinkManifest(nextDir);
|
||||||
await writeFile(temporary, `${before}\n`, { mode: 0o600 });
|
const certificationContents = `${JSON.stringify({
|
||||||
await rename(temporary, marker);
|
version: 1,
|
||||||
|
sourceFingerprint: before,
|
||||||
|
symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
|
||||||
|
})}\n`;
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
|
||||||
|
writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
|
||||||
|
]);
|
||||||
|
// The certification marker is the commit point. Publishing the manifest first
|
||||||
|
// leaves interrupted builds untrusted because the marker remains absent.
|
||||||
|
await rename(manifestTemporary, symlinkManifest);
|
||||||
|
await rename(certificationTemporary, certificationMarker);
|
||||||
} finally {
|
} finally {
|
||||||
await releaseLock();
|
await releaseLock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
@@ -26,19 +26,27 @@ test.after(async () => {
|
|||||||
await rm(fixtureRoot, { recursive: true, force: true });
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a successful web build atomically publishes its source fingerprint', async () => {
|
test('a successful web build atomically publishes its source and symlink certification', async () => {
|
||||||
const root = await fixture('success');
|
const root = await fixture('success');
|
||||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||||
|
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||||
|
|
||||||
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
|
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
|
||||||
|
|
||||||
assert.equal(await readFile(marker, 'utf8'), 'certified\n');
|
assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), {
|
||||||
|
version: 1,
|
||||||
|
sourceFingerprint: 'certified',
|
||||||
|
symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11',
|
||||||
|
});
|
||||||
|
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failed web build leaves no certification marker', async () => {
|
test('a failed web build leaves no certification marker', async () => {
|
||||||
const root = await fixture('failure');
|
const root = await fixture('failure');
|
||||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||||
|
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||||
await writeFile(marker, 'stale\n');
|
await writeFile(marker, 'stale\n');
|
||||||
|
await writeFile(manifest, 'stale\n');
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
buildWeb({
|
buildWeb({
|
||||||
@@ -52,12 +60,15 @@ test('a failed web build leaves no certification marker', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(await exists(marker), false);
|
assert.equal(await exists(marker), false);
|
||||||
|
assert.equal(await exists(manifest), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overlapping web builds are serialized while the marker remains absent', async () => {
|
test('overlapping web builds are serialized while the marker remains absent', async () => {
|
||||||
const root = await fixture('overlap');
|
const root = await fixture('overlap');
|
||||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||||
|
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||||
await writeFile(marker, 'stale\n');
|
await writeFile(marker, 'stale\n');
|
||||||
|
await writeFile(manifest, 'stale\n');
|
||||||
let releaseFirst;
|
let releaseFirst;
|
||||||
let secondEntered = false;
|
let secondEntered = false;
|
||||||
const firstEntered = new Promise((resolve) => {
|
const firstEntered = new Promise((resolve) => {
|
||||||
@@ -87,16 +98,41 @@ test('overlapping web builds are serialized while the marker remains absent', as
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||||
assert.equal(secondEntered, false);
|
assert.equal(secondEntered, false);
|
||||||
assert.equal(await exists(marker), false);
|
assert.equal(await exists(marker), false);
|
||||||
|
assert.equal(await exists(manifest), false);
|
||||||
|
|
||||||
releaseFirst();
|
releaseFirst();
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
assert.equal(secondEntered, true);
|
assert.equal(secondEntered, true);
|
||||||
assert.equal(await readFile(marker, 'utf8'), 'certified\n');
|
assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified');
|
||||||
|
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => {
|
||||||
|
const root = await fixture('symbolic-next');
|
||||||
|
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||||
|
const outside = path.join(root, 'outside-generated');
|
||||||
|
await mkdir(outside);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
buildWeb({
|
||||||
|
root,
|
||||||
|
fingerprint: async () => 'certified',
|
||||||
|
runBuild: async () => {
|
||||||
|
await rm(nextDir, { recursive: true });
|
||||||
|
await symlink(outside, nextDir);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/must be a real directory/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false);
|
||||||
|
assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('inputs changed during a web build are not certified', async () => {
|
test('inputs changed during a web build are not certified', async () => {
|
||||||
const root = await fixture('changed-inputs');
|
const root = await fixture('changed-inputs');
|
||||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||||
|
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||||
const fingerprints = ['before', 'after'];
|
const fingerprints = ['before', 'after'];
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
@@ -109,4 +145,5 @@ test('inputs changed during a web build are not certified', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(await exists(marker), false);
|
assert.equal(await exists(marker), false);
|
||||||
|
assert.equal(await exists(manifest), false);
|
||||||
});
|
});
|
||||||
|
|||||||
+62
-12
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import { access, lstat, readFile, readdir } from 'node:fs/promises';
|
import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -32,6 +32,19 @@ async function entries(root) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generatedSymlinkManifest(nextDir) {
|
||||||
|
const links = [];
|
||||||
|
for (const target of (await entries(nextDir)).sort()) {
|
||||||
|
const stats = await lstat(target);
|
||||||
|
if (!stats.isSymbolicLink()) continue;
|
||||||
|
links.push({
|
||||||
|
path: path.relative(nextDir, target).split(path.sep).join('/'),
|
||||||
|
target: await readlink(target),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return `${JSON.stringify({ version: 1, links })}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
const webSourceRoots = (root) => [
|
const webSourceRoots = (root) => [
|
||||||
path.join(root, 'apps', 'web', 'src'),
|
path.join(root, 'apps', 'web', 'src'),
|
||||||
path.join(root, 'apps', 'web', 'public'),
|
path.join(root, 'apps', 'web', 'public'),
|
||||||
@@ -150,7 +163,14 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid?
|
|||||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||||
let generated = [];
|
let generated = [];
|
||||||
try {
|
try {
|
||||||
await lstat(nextDir);
|
const nextStats = await lstat(nextDir);
|
||||||
|
if (nextStats.isSymbolicLink()) {
|
||||||
|
return {
|
||||||
|
code: GENERATED_STATE_EXIT,
|
||||||
|
message:
|
||||||
|
'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next contains a symbolic link and is not trustworthy; run pnpm clean:generated, then rerun the gate',
|
||||||
|
};
|
||||||
|
}
|
||||||
generated = [nextDir, ...(await entries(nextDir))];
|
generated = [nextDir, ...(await entries(nextDir))];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== 'ENOENT') throw error;
|
if (error.code !== 'ENOENT') throw error;
|
||||||
@@ -158,25 +178,55 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid?
|
|||||||
|
|
||||||
if (generated.length > 0) {
|
if (generated.length > 0) {
|
||||||
const foreign = [];
|
const foreign = [];
|
||||||
if (uid !== undefined) {
|
for (const target of generated) {
|
||||||
for (const target of generated) {
|
const stats = await lstat(target);
|
||||||
if ((await lstat(target)).uid !== uid) foreign.push(path.relative(root, target));
|
if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let generatedFingerprint = null;
|
// Threat model: this detects accidental, independent, or stale generated-state
|
||||||
|
// mutation. It does NOT defend against an actor with same-UID write access to
|
||||||
|
// the generated tree: that actor can regenerate both the manifest and marker
|
||||||
|
// consistently (CWE-345). No local construction can without a trust anchor
|
||||||
|
// outside that actor's authority.
|
||||||
|
let certification = null;
|
||||||
|
let certifiedManifest = null;
|
||||||
try {
|
try {
|
||||||
generatedFingerprint = (
|
const [certificationContents, manifestContents] = await Promise.all([
|
||||||
await readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8')
|
readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'),
|
||||||
).trim();
|
readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(certificationContents);
|
||||||
|
if (
|
||||||
|
parsed.version === 1 &&
|
||||||
|
typeof parsed.sourceFingerprint === 'string' &&
|
||||||
|
typeof parsed.symlinkManifestHash === 'string'
|
||||||
|
) {
|
||||||
|
certification = parsed;
|
||||||
|
certifiedManifest = manifestContents;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid certification is handled as untrusted generated state below.
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== 'ENOENT') throw error;
|
if (error.code !== 'ENOENT') throw error;
|
||||||
}
|
}
|
||||||
const stale = generatedFingerprint !== (await sourceFingerprint(root));
|
const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root));
|
||||||
if (foreign.length > 0 || stale) {
|
const actualManifest = await generatedSymlinkManifest(nextDir);
|
||||||
|
const certifiedManifestHash =
|
||||||
|
certifiedManifest === null
|
||||||
|
? null
|
||||||
|
: createHash('sha256').update(certifiedManifest).digest('hex');
|
||||||
|
const changedSymlinks =
|
||||||
|
certification?.symlinkManifestHash !== certifiedManifestHash ||
|
||||||
|
certifiedManifest !== actualManifest;
|
||||||
|
if (foreign.length > 0 || stale || changedSymlinks) {
|
||||||
const reasons = [
|
const reasons = [
|
||||||
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
||||||
stale ? 'generated source fingerprint does not match web source/configuration' : '',
|
stale ? 'generated source fingerprint does not match web source/configuration' : '',
|
||||||
|
changedSymlinks
|
||||||
|
? 'generated symbolic-link manifest does not match the certified build'
|
||||||
|
: '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return {
|
return {
|
||||||
code: GENERATED_STATE_EXIT,
|
code: GENERATED_STATE_EXIT,
|
||||||
|
|||||||
+115
-4
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
@@ -28,6 +29,22 @@ async function installRequiredBins(root) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function certifyGeneratedState(root, links = []) {
|
||||||
|
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||||
|
await mkdir(nextDir, { recursive: true });
|
||||||
|
const manifest = `${JSON.stringify({ version: 1, links })}\n`;
|
||||||
|
const manifestHash = createHash('sha256').update(manifest).digest('hex');
|
||||||
|
await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
|
||||||
|
await writeFile(
|
||||||
|
path.join(nextDir, '.mosaic-source-hash'),
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
sourceFingerprint: await sourceFingerprint(root),
|
||||||
|
symlinkManifestHash: manifestHash,
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test.after(async () => {
|
test.after(async () => {
|
||||||
await rm(fixtureRoot, { recursive: true, force: true });
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@@ -98,6 +115,103 @@ test('a generated marker mismatch is identified separately from source errors',
|
|||||||
assert.match(result.message, /pnpm clean:generated/);
|
assert.match(result.message, /pnpm clean:generated/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
|
||||||
|
await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
|
||||||
|
const root = await fixture('symbolic-next-root');
|
||||||
|
await installRequiredBins(root);
|
||||||
|
await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
|
||||||
|
await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));
|
||||||
|
|
||||||
|
const result = await runPreflight({ root });
|
||||||
|
assert.equal(result.code, 43);
|
||||||
|
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||||
|
assert.match(result.message, /symbolic link/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('an added descendant symlink is rejected', async () => {
|
||||||
|
const root = await fixture('symbolic-next-added');
|
||||||
|
await installRequiredBins(root);
|
||||||
|
await certifyGeneratedState(root);
|
||||||
|
await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));
|
||||||
|
|
||||||
|
const result = await runPreflight({ root });
|
||||||
|
assert.equal(result.code, 43);
|
||||||
|
assert.match(result.message, /symbolic-link manifest/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('a removed certified descendant symlink is rejected', async () => {
|
||||||
|
const root = await fixture('symbolic-next-removed');
|
||||||
|
await installRequiredBins(root);
|
||||||
|
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||||
|
await mkdir(path.dirname(link), { recursive: true });
|
||||||
|
await symlink('../dependency-one', link);
|
||||||
|
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||||
|
await rm(link);
|
||||||
|
|
||||||
|
const result = await runPreflight({ root });
|
||||||
|
assert.equal(result.code, 43);
|
||||||
|
assert.match(result.message, /symbolic-link manifest/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('a retargeted certified descendant symlink is rejected', async () => {
|
||||||
|
const root = await fixture('symbolic-next-retargeted');
|
||||||
|
await installRequiredBins(root);
|
||||||
|
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||||
|
await mkdir(path.dirname(link), { recursive: true });
|
||||||
|
await symlink('../dependency-one', link);
|
||||||
|
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||||
|
await rm(link);
|
||||||
|
await symlink('../dependency-two', link);
|
||||||
|
|
||||||
|
const result = await runPreflight({ root });
|
||||||
|
assert.equal(result.code, 43);
|
||||||
|
assert.match(result.message, /symbolic-link manifest/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
|
||||||
|
const root = await fixture('symbolic-next-tampered-manifest');
|
||||||
|
await installRequiredBins(root);
|
||||||
|
await certifyGeneratedState(root);
|
||||||
|
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||||
|
await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
|
||||||
|
await writeFile(
|
||||||
|
path.join(nextDir, '.mosaic-symlink-manifest'),
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runPreflight({ root });
|
||||||
|
assert.equal(result.code, 43);
|
||||||
|
assert.match(result.message, /symbolic-link manifest/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
|
||||||
|
const root = await fixture('symbolic-next-certified');
|
||||||
|
await installRequiredBins(root);
|
||||||
|
const link = path.join(
|
||||||
|
root,
|
||||||
|
'apps',
|
||||||
|
'web',
|
||||||
|
'.next',
|
||||||
|
'standalone',
|
||||||
|
'node_modules',
|
||||||
|
'dependency',
|
||||||
|
);
|
||||||
|
await mkdir(path.dirname(link), { recursive: true });
|
||||||
|
await symlink('../.pnpm/dependency', link);
|
||||||
|
await certifyGeneratedState(root, [
|
||||||
|
{ path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(await runPreflight({ root }), {
|
||||||
|
code: 0,
|
||||||
|
message: 'checkout preflight passed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
||||||
const root = await fixture('inherited-typescript-config');
|
const root = await fixture('inherited-typescript-config');
|
||||||
const config = path.join(root, 'tsconfig.base.json');
|
const config = path.join(root, 'tsconfig.base.json');
|
||||||
@@ -143,10 +257,7 @@ test('a matching generation marker accepts incremental output with mixed mtimes'
|
|||||||
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
||||||
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
||||||
await writeFile(fresh, 'fresh generated output');
|
await writeFile(fresh, 'fresh generated output');
|
||||||
await writeFile(
|
await certifyGeneratedState(root);
|
||||||
path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'),
|
|
||||||
await sourceFingerprint(root),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user