LLP 1030.007 D4, D5. Deploy published to an Android stream that already had a head but started none, and told the developer to run push instead. The launcher now passes the app's production Android receipts to the captured process, and `streams` starts a stream from one with no head, as it does for push: the row's cohort is the APK's, and a note names the receipt that anchors it. Android's binary rows say "new APK needed" with the command that builds one: no stream and no receipt, `deploy.store.android` "0", or native evidence that differs from the APK's. When the data crate the binary runs has changed (`dataCrate` moved), deploy withholds the bundle from that stream, since the plan's first frame was baked by the new crate. Verified: bun test scripts/deploy.test.mjs scripts/push.test.mjs; on an Android 37.2 emulator, deploy --yes published an edit to the stream push had started, and on a fresh origin deploy alone started the stream from the receipt; both arrived. A data-crate edit showed a binary row and no bundle. Part of #588 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1309 lines
96 KiB
JavaScript
1309 lines
96 KiB
JavaScript
// The publisher and its origin, driven: stream heads, admission and sequence
|
|
// allocation, classification tables, signing keys, source snapshots, stream
|
|
// commits and origin handles (`scripts/deploy.mjs`, `scripts/origin.mjs`).
|
|
// `bun test ./scripts/deploy.test.mjs`.
|
|
import { expect, test } from 'bun:test';
|
|
// These cases run cargo (the filesystem tool, bakes, locks). A shell whose PATH
|
|
// omits rustup's bin directory still finds it there; without cargo, say so.
|
|
const cargoBin = `${process.env.CARGO_HOME ?? `${process.env.HOME}/.cargo`}/bin`;
|
|
if (!(process.env.PATH ?? '').split(':').includes(cargoBin)) process.env.PATH = `${process.env.PATH ?? ''}:${cargoBin}`;
|
|
if (!Bun.which('cargo', { PATH: process.env.PATH })) throw new Error(`these tests need cargo: put it on PATH or in ${cargoBin}`);
|
|
// The fixtures name their apps; a caller's EXACT_APP_DIR would redirect every one.
|
|
delete process.env.EXACT_APP_DIR;
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as cryptoSign } from 'node:crypto';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, writeFileSync, rmSync, symlinkSync, utimesSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { isAbsolute, join, dirname, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { captureAssets, assetCards, copyStaticTreeIfPresent, webEnvelope, publicFileCards, listAssets, readStaticFile, readDevGeneration, retainDevGeneration } from '../host/web/serve.mjs';
|
|
import { resolveApp, webGpuArtifacts, withAppFixture } from './app.mjs';
|
|
import { streamBundle } from './texture-families.mjs';
|
|
import { bake, sealEnvelope, canonicalBytes, classify, classifyArtifacts, cohortReceipt, deployRun, holdBundles, inspectHead, disposeSnapshot, materializeSnapshot, readAssetIndex, portableAssetNames, publishRoot, renderTable, snapshotOf as capturedSnapshotOf } from './deploy.mjs';
|
|
import { defaultRelease, publishStream, streamHead, streamIndex } from './publisher.mjs';
|
|
import { blobPath, DirectoryOrigin, HttpsOrigin, OriginUnavailable, sha256, webReleasePath, webRootPath } from './origin.mjs';
|
|
// Minimal compiler receipts for origin protocol fixtures below; capability
|
|
// behavior is tested independently with declared requirements.
|
|
const fixtureBuild = (id, bundle) => ({version:1,compat:{id,inputs:{store:{L:'A'},executors:[]},target:'fixture'},
|
|
graph:{version:1,sources:{},artifacts:[{name:'app.plan',requires:{},sha256:bundle.plan.sha256,bytes:bundle.plan.bytes?.length??0}]},binary:{sha256:'0'.repeat(64)}});
|
|
const DEPLOY = join(dirname(fileURLToPath(import.meta.url)), 'deploy.mjs');
|
|
|
|
test('deployment ships the web GPU product with an inherited gpu-dev proof profile', async () => {
|
|
const root = resolve(import.meta.dir, '..'), app = resolveApp('beacons');
|
|
const run = deployRun(app.target, 'profile');
|
|
const previous = process.env.EXACT_GAME_PROOF_PROFILE;
|
|
try {
|
|
process.env.EXACT_GAME_PROOF_PROFILE = 'gpu-dev';
|
|
const web = bake(app, run, root, root);
|
|
const receipt = JSON.parse(readFileSync(join(web, 'bake.json')));
|
|
expect(receipt.trust).toBe('production');
|
|
const products = receipt.products.map(p => p.path);
|
|
expect(products).toContain(join(app.target, 'wasm32-unknown-unknown/web/beacons_gpu.wasm'));
|
|
expect(products).not.toContain(join(app.target, 'wasm32-unknown-unknown/gpu-dev/beacons_gpu.wasm'));
|
|
const expected = join(run, 'shipping');
|
|
mkdirSync(expected);
|
|
webGpuArtifacts(app, expected, { profile: 'web' });
|
|
const origin = new DirectoryOrigin(join(run, 'origin'));
|
|
await publishRoot({ origin, row: {}, web });
|
|
const pointer = JSON.parse(await origin.get(webRootPath));
|
|
const shipped = await origin.get(`${webReleasePath(pointer.id)}/gpu_bg.wasm`);
|
|
expect(shipped).toEqual(readFileSync(join(expected, 'gpu_bg.wasm')));
|
|
expect(process.env.EXACT_GAME_PROOF_PROFILE).toBe('gpu-dev');
|
|
} finally {
|
|
if (previous === undefined) delete process.env.EXACT_GAME_PROOF_PROFILE;
|
|
else process.env.EXACT_GAME_PROOF_PROFILE = previous;
|
|
rmSync(run, { recursive: true, force: true });
|
|
}
|
|
}, 600000);
|
|
|
|
// Each case is checked while the file loads and reported as a bun:test test.
|
|
function result(name, ok, detail = '') {
|
|
test(name, () => { if (!ok) throw new Error(detail || `${name}: failed`); });
|
|
}
|
|
async function rejects(action, matches) {
|
|
try { await action(); return false; } catch (error) { return matches(error); }
|
|
}
|
|
{
|
|
const id = 'com.exact.names';
|
|
const bytes = Buffer.alloc(36 + Buffer.byteLength(id));
|
|
bytes.write('EXPL'); bytes.writeUInt32LE(4, 4); bytes.writeBigUInt64LE(1n, 16);
|
|
bytes.writeUInt32LE(Buffer.byteLength(id), 32); bytes.write(id, 36);
|
|
const app = { name: 'internal-slug', id, displayName: 'Cross-platform Display', manifest: { name: 'Web Install Name' } };
|
|
const web = webEnvelope(app, bytes, []);
|
|
const mismatchedPlanRefused = await rejects(() => webEnvelope({ ...app, id: 'com.exact.another' }, bytes, []), error => error.message.includes(`plan is for ${id}`));
|
|
const stream = streamHead({ app, bundle: { plan: { bytes, sha256: web.plan.sha256, formatVersion: 4, kernelSchema: '0000000000000001' }, assets: [] },
|
|
stream: { channel: 'prod', compatibilityId: 'a'.repeat(32) }, seq: 1, release: 'test' });
|
|
result('web and stream envelopes use the cross-platform display name', app.name !== app.manifest.name
|
|
&& app.manifest.name !== app.displayName && web.app.name === app.displayName
|
|
&& stream.app.name === web.app.name && mismatchedPlanRefused);
|
|
}
|
|
// "Current" means the production client admits the complete authenticated
|
|
// head. An unusable head contributes no rollback floor: repair advances from
|
|
// the largest immutable record whose embedded envelope verifies, or refuses
|
|
// when no such history exists. Raw JSON spellings rejected by Rust are also
|
|
// rejected before JavaScript can normalize them. The locked publisher repeats
|
|
// the same admission in case the head changed after the table was printed.
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-head-admission-'));
|
|
const compatibilityId = 'a'.repeat(32);
|
|
const stream = { channel: 'prod', compatibilityId };
|
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
const publicRaw = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('base64');
|
|
const app = { id: 'com.exact.admission', displayName: 'Admission', dir,
|
|
manifest: { deploy: { signing: { key: 'test', keys: { test: publicRaw } } } } };
|
|
const planBytes = Buffer.from('same plan');
|
|
const assetBytes = Buffer.from('same asset');
|
|
const bundle = {
|
|
plan: { bytes: planBytes, sha256: createHash('sha256').update(planBytes).digest('hex'), formatVersion: 4, kernelSchema: '0'.repeat(16) },
|
|
assets: [{ name: 'assets/icon.png', bytes: assetBytes, sha256: createHash('sha256').update(assetBytes).digest('hex') }],
|
|
};
|
|
const signer = { keyId: 'test', sign: (head) => ({ keyId: 'test', ed25519: cryptoSign(null, canonicalBytes(head), privateKey).toString('base64') }) };
|
|
const signed = (edit = () => {}) => {
|
|
const head = streamHead({ app, bundle, stream, seq: 7, release: 'old' });
|
|
head.cohort = cohortReceipt(fixtureBuild(compatibilityId,bundle));
|
|
edit(head);
|
|
head.signature = signer.sign(head);
|
|
return head;
|
|
};
|
|
const found = (head) => {
|
|
const bytes = Buffer.from(JSON.stringify(head) + '\n');
|
|
return { json: head, bytes, sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
};
|
|
const rawFound = (text) => {
|
|
const bytes = Buffer.from(text + '\n');
|
|
return { json: JSON.parse(text), bytes, sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
};
|
|
const valid = signed();
|
|
// The head's asset index, a blob the origin must also serve.
|
|
const index = streamIndex(bundle), indexPath = blobPath(index.card.sha256);
|
|
// A host display-name change repairs metadata without losing signed capabilities.
|
|
const renameDir=mkdtempSync(join(tmpdir(),'exact-renamed-cohort-')), renameOrigin=new DirectoryOrigin(renameDir);
|
|
await renameOrigin.put(indexPath,index.bytes,{immutable:true});
|
|
await renameOrigin.putHead(stream,found(valid).bytes,{previousDigest:null});
|
|
await renameOrigin.put(`.exact/${stream.channel}/${compatibilityId}/releases/old.json`,Buffer.from(JSON.stringify({platform:'linux',envelope:valid})),{immutable:true});
|
|
const renamed={...app,displayName:'Renamed'}, build=fixtureBuild(compatibilityId,bundle);
|
|
const renameArgs={app:renamed,opts:{platform:['linux']},origin:renameOrigin,channel:stream.channel,snapshot:{commit:'rename',dirty:false,changes:[],repo:dir},release:'rename',bundle,compat:{linux:build.compat},builds:{linux:build},platforms:['linux'],wantOrigin:false};
|
|
const renameTable=await classify(renameArgs), renameRow=renameTable.rows.find(r=>r.kind==='stream');
|
|
const renamePublished=await publishStream({origin:renameOrigin,row:renameRow,bundle,compat:build.compat,app:renamed,signer,release:'rename',snapshot:renameArgs.snapshot,opts:{},log:()=>{},build});
|
|
const changed=structuredClone(build);changed.graph.artifacts[0].requires.sources={camera:{params:[],result:'String'}};
|
|
const afterRename=await classify({...renameArgs,release:'unsupported',builds:{linux:changed}});
|
|
const renamedHead=await renameOrigin.head(stream);
|
|
result('display-name repair preserves the authenticated cohort and still refuses new capabilities',renameRow.action==='bundle'&&renamePublished.seq===8
|
|
&&renamedHead.json.app.name==='Renamed'&&renamedHead.json.stream.seq===8&&inspectHead(renamedHead,renamed,stream).authenticated
|
|
&&afterRename.rows.find(r=>r.kind==='stream').reason.includes('sources.camera'));
|
|
const foreign='deadbeefdeadbeefdeadbeefdeadbeef', forged=structuredClone(renamedHead.json);forged.stream.compatibilityId=foreign;
|
|
await renameOrigin.putHead({...stream,compatibilityId:foreign},Buffer.from(JSON.stringify(forged)),{previousDigest:null});
|
|
const independent=await classify({...renameArgs,release:'unrelated'});
|
|
result('an unauthenticated unrelated stream cannot block a healthy cohort',independent.rows.some(r=>r.compatibilityId===compatibilityId&&r.action==='current')
|
|
&&independent.rows.some(r=>r.compatibilityId===foreign&&r.action==='binary')&&independent.notes.some(n=>n.includes(foreign)&&n.includes('does not verify')));
|
|
rmSync(renameDir,{recursive:true,force:true});
|
|
const history = Buffer.from(JSON.stringify({ envelope: valid }) + '\n');
|
|
const tableFor = (candidate, withHistory = true, withIndex = true) => classify({ app, opts: { platform: [] },
|
|
origin: {
|
|
kind: 'directory', describe: () => dir,
|
|
get: async (name) => withHistory && name.endsWith('/releases/old.json') ? history : withIndex && name === indexPath ? index.bytes : null,
|
|
head: async () => candidate === null ? null : candidate?.bytes ? candidate : found(candidate),
|
|
list: async (name) => withHistory && name.endsWith('/releases') ? ['old.json'] : [],
|
|
},
|
|
channel: stream.channel, snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'next', web: dir, bundle, builds:{linux:fixtureBuild(compatibilityId,bundle)}, compat: { linux: { id: compatibilityId, inputs: { store: { L: 'A' }, executors: [] } } },
|
|
platforms: ['linux'], wantOrigin: false });
|
|
const missingSignature = signed(); delete missingSignature.signature;
|
|
const badSignature = signed(); badSignature.signature.ed25519 = Buffer.alloc(64).toString('base64');
|
|
const forgedLow = signed((head) => { head.stream.seq = 2; }); forgedLow.signature.ed25519 = Buffer.alloc(64).toString('base64');
|
|
const forgedHuge = signed((head) => { head.stream.seq = Number.MAX_SAFE_INTEGER; }); forgedHuge.signature.ed25519 = Buffer.alloc(64).toString('base64');
|
|
const noSeq = signed((head) => { head.stream.seq = 'seven'; });
|
|
const decimal = rawFound(JSON.stringify(valid).replace('"seq":7', '"seq":7.0'));
|
|
const exponent = rawFound(JSON.stringify(valid).replace('"seq":7', '"seq":7e0'));
|
|
const zeroHead = signed((head) => { head.unknownNumber = 0; });
|
|
const negativeZero = rawFound(JSON.stringify(zeroHead).replace('"unknownNumber":0', '"unknownNumber":-0'));
|
|
const scalarHead = { ...valid, unknownText: String.fromCharCode(0xd800) };
|
|
const loneSurrogate = rawFound(JSON.stringify(scalarHead));
|
|
const variants = [
|
|
missingSignature,
|
|
badSignature,
|
|
forgedLow,
|
|
forgedHuge,
|
|
noSeq,
|
|
decimal,
|
|
exponent,
|
|
negativeZero,
|
|
loneSurrogate,
|
|
signed((head) => { head.stream.channel = 'beta'; }),
|
|
signed((head) => { head.exact = 2; }),
|
|
signed((head) => { delete head.plan.bytes; }),
|
|
];
|
|
const validTable = await tableFor(valid);
|
|
// A signed head whose asset index the origin lacks is repaired, not current.
|
|
const missingIndexTable = await tableFor(valid, true, false);
|
|
const reordered = { cohort: valid.cohort, signature: valid.signature, stream: valid.stream, release: valid.release,
|
|
plan: valid.plan, exact: valid.exact, assets: valid.assets, app: valid.app };
|
|
const prettyBytes = Buffer.from(JSON.stringify(reordered, null, 2) + '\n');
|
|
const pretty = { json: reordered, bytes: prettyBytes, sha256: createHash('sha256').update(prettyBytes).digest('hex') };
|
|
const numberedKeys = signed((head) => { head.unknownKeys = { 2: 'two', 10: 'ten' }; });
|
|
const numberedCanonical = canonicalBytes(numberedKeys).toString('utf8');
|
|
const surrogateValueRefused = await rejects(() => canonicalBytes({ value: String.fromCharCode(0xd800) }), error => error.message.includes('not a Unicode scalar value'));
|
|
const surrogateKeyRefused = await rejects(() => canonicalBytes({ [String.fromCharCode(0xdc00)]: 'value' }), error => error.message.includes('not a Unicode scalar value'));
|
|
const repaired = await Promise.all(variants.map((candidate) => tableFor(candidate)));
|
|
const missingHeadTable = await tableFor(null);
|
|
const emptyHeadTable = await tableFor(null, false);
|
|
const unlistableTable = await classify({ app, opts: { platform: [] },
|
|
origin: {
|
|
kind: 'https', describe: () => 'https://origin.example', get: async () => null,
|
|
head: async () => null, list: async () => null,
|
|
},
|
|
channel: stream.channel, snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'next', web: dir, bundle, builds:{linux:fixtureBuild(compatibilityId,bundle)}, compat: { linux: { id: compatibilityId, inputs: { store: { L: 'A' }, executors: [] } } },
|
|
platforms: ['linux'], wantOrigin: false });
|
|
const noHistoryRefused = await rejects(async () => await tableFor(badSignature, false), error => error.message.includes('release history has no authenticated sequence floor'));
|
|
let unknownHistoryRefused = false;
|
|
let unknownHistoryWrote = false;
|
|
const unknownOrigin = {
|
|
kind: 'object', describe: () => 'unknown-object-origin', get: async () => null,
|
|
head: async () => null, list: async () => null,
|
|
withLock: async (_stream, body) => body(),
|
|
put: async () => { unknownHistoryWrote = true; },
|
|
putHead: async () => { unknownHistoryWrote = true; },
|
|
};
|
|
try {
|
|
await publishStream({ origin: unknownOrigin,
|
|
row: { kind: 'stream', platform: 'linux', channel: stream.channel, compatibilityId, action: 'bundle', changes: [] },
|
|
bundle, compat: { id: compatibilityId, inputs: {} }, app, signer, release: 'unknown-history',
|
|
snapshot: { commit: '0'.repeat(40), dirty: false, changes: [] }, opts: {}, log: () => {} });
|
|
} catch (error) { unknownHistoryRefused = error.message.includes('cannot enumerate its authenticated release history'); }
|
|
const badHistoryOrigin = (bytes) => ({
|
|
kind: 'directory', describe: () => 'bad-history-origin',
|
|
get: async (name) => name.endsWith('/releases/broken.json') ? bytes : null,
|
|
head: async () => null, list: async () => ['broken.json'],
|
|
});
|
|
const foreignHistory = Buffer.from(JSON.stringify({ envelope: signed((head) => { head.app.id = 'com.exact.foreign'; }) }) + '\n');
|
|
const badHistories = [Buffer.from('{not json'), Buffer.from(JSON.stringify({ envelope: missingSignature }) + '\n'), foreignHistory];
|
|
const badHistoryClassifyRefused = [];
|
|
for (const bytes of badHistories) {
|
|
try {
|
|
await classify({ app, opts: { platform: [] }, origin: badHistoryOrigin(bytes),
|
|
channel: stream.channel, snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'next', web: dir, bundle, builds:{linux:fixtureBuild(compatibilityId,bundle)}, compat: { linux: { id: compatibilityId, inputs: { store: { L: 'A' }, executors: [] } } },
|
|
platforms: ['linux'], wantOrigin: false });
|
|
badHistoryClassifyRefused.push(false);
|
|
} catch (error) { badHistoryClassifyRefused.push(error.message.includes('nonempty release history has no authenticated sequence floor')); }
|
|
}
|
|
let corruptHistoryPublishRefused = false;
|
|
let corruptHistoryWrote = false;
|
|
try {
|
|
await publishStream({ origin: {
|
|
...badHistoryOrigin(Buffer.from('{not json')), withLock: async (_stream, body) => body(),
|
|
put: async () => { corruptHistoryWrote = true; }, putHead: async () => { corruptHistoryWrote = true; },
|
|
}, row: { kind: 'stream', platform: 'linux', channel: stream.channel, compatibilityId, action: 'bundle', changes: [] },
|
|
bundle, compat: { id: compatibilityId, inputs: {} }, app, signer, release: 'corrupt-history',
|
|
snapshot: { commit: '0'.repeat(40), dirty: false, changes: [] }, opts: {}, log: () => {} });
|
|
} catch (error) { corruptHistoryPublishRefused = error.message.includes('nonempty release history has no authenticated sequence floor'); }
|
|
const vanishedTable = await classify({ app, opts: { platform: [] }, origin: badHistoryOrigin(null),
|
|
channel: stream.channel, snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'next', web: dir, bundle, builds:{linux:fixtureBuild(compatibilityId,bundle)}, compat: { linux: { id: compatibilityId, inputs: { store: { L: 'A' }, executors: [] } } },
|
|
platforms: ['linux'], wantOrigin: false });
|
|
const vanishedHistoryUnavailable = vanishedTable.rows[0].action === 'unavailable'
|
|
&& vanishedTable.rows[0].reason.includes('disappeared while establishing');
|
|
const origin = new DirectoryOrigin(dir);
|
|
mkdirSync(join(dir, '.exact', stream.channel, compatibilityId), { recursive: true });
|
|
writeFileSync(join(dir, '.exact', stream.channel, compatibilityId, 'exact.json'), found(forgedHuge).bytes);
|
|
await origin.put(`.exact/${stream.channel}/${compatibilityId}/releases/old.json`, history, { immutable: true });
|
|
const locked = await publishStream({ origin,
|
|
row: { kind: 'stream', platform: 'linux', channel: stream.channel, compatibilityId, action: 'current', changes: [] },
|
|
bundle, compat: { id: compatibilityId, inputs: {} }, app, signer, release: 'locked-repair',
|
|
snapshot: { commit: '0'.repeat(40), dirty: false, changes: [] }, opts: {}, log: () => {} });
|
|
const repairedHead = await origin.head(stream);
|
|
const missingDir = mkdtempSync(join(tmpdir(), 'exact-missing-head-'));
|
|
const missingOrigin = new DirectoryOrigin(missingDir);
|
|
await missingOrigin.put(`.exact/${stream.channel}/${compatibilityId}/releases/old.json`, history, { immutable: true });
|
|
const missingPublished = await publishStream({ origin: missingOrigin,
|
|
row: { kind: 'stream', platform: 'linux', channel: stream.channel, compatibilityId, action: 'bundle', changes: [] },
|
|
bundle, compat: { id: compatibilityId, inputs: {} }, app, signer, release: 'after-missing',
|
|
snapshot: { commit: '0'.repeat(40), dirty: false, changes: [] }, opts: {}, log: () => {} });
|
|
const hiddenDir = mkdtempSync(join(tmpdir(), 'exact-hidden-history-'));
|
|
const hiddenOrigin = new DirectoryOrigin(hiddenDir);
|
|
await hiddenOrigin.put(`.exact/${stream.channel}/${compatibilityId}/releases/.old.json`, history, { immutable: true });
|
|
const hiddenTable = await classify({ app, opts: { platform: [] }, origin: hiddenOrigin,
|
|
channel: stream.channel, snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'after-hidden', web: dir, bundle, builds:{linux:fixtureBuild(compatibilityId,bundle)}, compat: { linux: { id: compatibilityId, inputs: { store: { L: 'A' }, executors: [] } } },
|
|
platforms: ['linux'], wantOrigin: false });
|
|
const hiddenPublished = await publishStream({ origin: hiddenOrigin,
|
|
row: { kind: 'stream', platform: 'linux', channel: stream.channel, compatibilityId, action: 'bundle', changes: [] },
|
|
bundle, compat: { id: compatibilityId, inputs: {} }, app, signer, release: 'after-hidden',
|
|
snapshot: { commit: '0'.repeat(40), dirty: false, changes: [] }, opts: {}, log: () => {} });
|
|
const hiddenReleaseCli = spawnSync(process.execPath,
|
|
[DEPLOY, 'caltrain', '--release', '.hidden', '--json'],
|
|
{ cwd: join(dirname(DEPLOY), '..'), encoding: 'utf8' });
|
|
result('deploy calls only an admitted authenticated head current', validTable.rows[0].action === 'current'
|
|
&& validTable.rows[0].seq === 7 && inspectHead(found(valid), app, stream).usable && inspectHead(pretty, app, stream).usable
|
|
&& inspectHead(found(numberedKeys), app, stream).usable
|
|
&& numberedCanonical.includes('"unknownKeys":{"10":"ten","2":"two"}')
|
|
&& surrogateValueRefused && surrogateKeyRefused
|
|
&& missingHeadTable.rows[0].action === 'bundle' && missingHeadTable.rows[0].seq === 8
|
|
&& emptyHeadTable.rows[0].action === 'bundle' && emptyHeadTable.rows[0].seq === 1
|
|
&& unlistableTable.rows[0].action === 'unavailable'
|
|
&& unlistableTable.rows[0].reason.includes('cannot enumerate its authenticated release history')
|
|
&& [...repaired, missingIndexTable].every((table) => table.rows[0].action === 'bundle' && table.rows[0].seq === 8
|
|
&& table.rows[0].changes[0].name === 'exact.json' && table.rows[0].changes[0].change === 'repair')
|
|
&& noHistoryRefused && unknownHistoryRefused && !unknownHistoryWrote
|
|
&& badHistoryClassifyRefused.every(Boolean) && corruptHistoryPublishRefused && !corruptHistoryWrote
|
|
&& vanishedHistoryUnavailable
|
|
&& locked.action === 'published' && locked.seq === 8
|
|
&& missingPublished.action === 'published' && missingPublished.seq === 8
|
|
&& hiddenTable.rows[0].seq === 8 && hiddenPublished.seq === 8
|
|
&& hiddenReleaseCli.status === 1 && hiddenReleaseCli.stderr.includes('start with a letter or digit')
|
|
&& locked.changes[0].change === 'repair' && inspectHead(repairedHead, app, stream).usable,
|
|
JSON.stringify({ missingHead: missingHeadTable.rows[0], emptyHead: emptyHeadTable.rows[0], unlistable: unlistableTable.rows[0],
|
|
noHistoryRefused, unknownHistoryRefused, unknownHistoryWrote, badHistoryClassifyRefused, corruptHistoryPublishRefused, corruptHistoryWrote,
|
|
vanishedHistoryUnavailable, hiddenTable: hiddenTable.rows[0], hiddenPublished: { action: hiddenPublished.action, seq: hiddenPublished.seq },
|
|
hiddenReleaseCli: { status: hiddenReleaseCli.status, stderr: hiddenReleaseCli.stderr },
|
|
locked: { action: locked.action, seq: locked.seq }, missingPublished: { action: missingPublished.action, seq: missingPublished.seq } }));
|
|
rmSync(hiddenDir, { recursive: true, force: true });
|
|
rmSync(missingDir, { recursive: true, force: true });
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-list-outage-'));
|
|
const cohort = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
const origin = {
|
|
kind: 'directory', describe: () => dir, get: async () => null, head: async () => null,
|
|
list: async (name) => { if (name.endsWith('/releases')) return null; throw new OriginUnavailable('EACCES'); },
|
|
};
|
|
const table = await classify({ app: { id: 'com.exact.test', displayName: 'Test', dir, manifest: {} }, opts: { platform: [] },
|
|
origin, channel: 'prod', snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'test', web: dir, bundle: { plan: { sha256: '0'.repeat(64) }, assets: [] },
|
|
builds:{linux:fixtureBuild(cohort,{plan:{sha256:'0'.repeat(64)}})}, compat: { linux: { id: cohort, inputs: { store: { L: 'A' }, executors: [] } } }, platforms: ['linux'], wantOrigin: false });
|
|
result('stream discovery outage retains the classified cohort row', table.rows.filter(r=>r.kind==='stream').length === 1
|
|
&& table.rows[0].action === 'bundle' && table.notes.some((note) => note.includes('stream discovery unavailable')),
|
|
JSON.stringify(table));
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
// Android's binary is the APK, which its own verb builds (LLP 1030.006 D6, LLP 1030.007 D5): with no stream and no
|
|
// production APK receipt, the table names the APK's command.
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-android-binary-'));
|
|
const origin = { kind: 'directory', describe: () => dir, get: async () => null, head: async () => null, list: async () => [] };
|
|
const table = async (store) => classify({ app: { id: 'com.exact.test', displayName: 'Test', dir, manifest: { deploy: { store: { android: store } } } },
|
|
opts: { platform: [] }, origin, channel: 'prod', snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'test', web: dir, bundle: { plan: { sha256: '0'.repeat(64) }, assets: [] }, builds: {}, compat: {}, platforms: ['android'], wantOrigin: false });
|
|
const updating = await table('A'), fixed = await table('0');
|
|
const rendered = renderTable(updating);
|
|
result('an Android deploy with no stream names the production APK command', updating.rows.length === 1
|
|
&& updating.rows[0].kind === 'binary' && updating.rows[0].reason.includes('EXACT_UPDATE_TRUST=production bun exact.mjs android --release')
|
|
&& updating.rows[0].reason.includes('EXACT_UPDATE_GENESIS=1') && fixed.rows[0].reason.startsWith('deploy.store.android is "0"')
|
|
&& rendered.includes('new APK needed: android — EXACT_UPDATE_TRUST=production bun exact.mjs android --release'), rendered);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
// A production APK receipt with no head starts its stream: the row's cohort is that APK's, and a note names the
|
|
// receipt (LLP 1030.007 D4).
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-android-start-'));
|
|
const origin = { kind: 'directory', describe: () => dir, get: async () => null, head: async () => null, list: async () => [] };
|
|
const id = 'a'.repeat(32), bundle = { plan: { sha256: '1'.repeat(64) }, assets: [] };
|
|
const apk = fixtureBuild(id, bundle);
|
|
apk.compat.inputs.platform = 'android';
|
|
const cohort = cohortReceipt(apk), stream = { channel: 'prod', compatibilityId: id };
|
|
const table = await classify({ app: { id: 'com.exact.test', displayName: 'Test', dir, manifest: { deploy: { store: { android: 'A' } } } },
|
|
opts: { platform: [] }, origin, channel: 'prod', snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'test', web: dir, bundle, builds: {}, compat: {}, platforms: ['android'], wantOrigin: false,
|
|
knownStreams: [{ stream, head: null, cohort, platform: 'android', build: apk }],
|
|
candidates: new Map([[id, { build: apk, bundle }]]), anchors: new Map([[id, '/receipts/android-aarch64-linux-android.build.json']]) });
|
|
const [row] = table.rows;
|
|
result('a production APK receipt starts its Android stream with that APK as the cohort', table.rows.length === 1
|
|
&& row.kind === 'stream' && row.platform === 'android' && row.action === 'bundle' && row.receipt === cohort && row.head === null
|
|
&& table.notes.some((note) => note === `android stream ${id.slice(0, 8)} starts from the production build receipt /receipts/android-aarch64-linux-android.build.json: binary ${'0'.repeat(12)}`),
|
|
JSON.stringify(table));
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
// A dry-run renders network availability per row, including declared retired
|
|
// streams. A corrupt response remains a hard refusal rather than masquerading
|
|
// as a transient network problem.
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-origin-table-'));
|
|
writeFileSync(join(dir, 'index.html'), 'web');
|
|
const app = { id: 'com.exact.test', displayName: 'Test', dir,
|
|
manifest: { deploy: { streams: [{ channel: 'prod', compatibilityId: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }] } } };
|
|
const compat = {
|
|
web: { id: 'wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww', inputs: {} },
|
|
linux: { id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', inputs: { store: { L: 'A' }, executors: [] } },
|
|
};
|
|
const table = await classify({ app, opts: { platform: [] },
|
|
origin: new HttpsOrigin('https://127.0.0.1:1'), channel: 'prod',
|
|
snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir }, release: 'test', web: dir,
|
|
bundle: { plan: { sha256: '0'.repeat(64) }, assets: [] }, compat, platforms: ['linux'], wantOrigin: true });
|
|
const unavailable = table.rows.filter((row) => row.action === 'unavailable');
|
|
result('deploy tables preserve every row when HTTPS is unavailable', unavailable.length === 3
|
|
&& unavailable.some((row) => row.kind === 'origin')
|
|
&& unavailable.some((row) => row.platform === 'linux')
|
|
&& unavailable.some((row) => row.compatibilityId.startsWith('bbbb')),
|
|
JSON.stringify(table.rows));
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-bad-head-'));
|
|
const cohort = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
mkdirSync(join(dir, '.exact', 'prod', cohort), { recursive: true });
|
|
writeFileSync(join(dir, '.exact', 'prod', cohort, 'exact.json'), '{not json');
|
|
let message = '';
|
|
try {
|
|
await classify({ app: { id: 'com.exact.test', displayName: 'Test', dir, manifest: {} }, opts: { platform: [] },
|
|
origin: new DirectoryOrigin(dir), channel: 'prod', snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'test', web: dir, bundle: { plan: { sha256: '0'.repeat(64) }, assets: [] },
|
|
compat: { linux: { id: cohort, inputs: { store: { L: 'A' }, executors: [] } } }, platforms: ['linux'], wantOrigin: false });
|
|
} catch (error) { message = error.message; }
|
|
result('deploy does not hide a malformed head as unavailable', message.includes('is not JSON'), message);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
// One comparison drives dev and deploy; neither id equality nor a binary
|
|
// edit is a proxy for a bundle dependency.
|
|
{
|
|
const candidate=fixtureBuild('a'.repeat(32),{plan:{sha256:'0'.repeat(64)}});
|
|
candidate.compat.inputs={app:'test',store:{L:'A'},executors:['native'],grantCeiling:'net.fetch https://x/',gpuSurfaces:[{name:'map',interface:'v1'}],dataCrate:{tree:'old'}};
|
|
candidate.graph.sources={station:{params:['String'],result:'String'}};
|
|
candidate.graph.artifacts[0].requires={sources:candidate.graph.sources,executors:['native'],grantCeiling:'net.fetch https://x/'};
|
|
const installed=cohortReceipt(candidate);
|
|
const host=structuredClone(candidate);host.binary.sha256='1'.repeat(64);
|
|
const same=classifyArtifacts(host,installed);
|
|
const moved=structuredClone(host);moved.compat.id='b'.repeat(32);moved.compat.inputs.dataCrate.tree='new';
|
|
const old=classifyArtifacts(moved,installed);
|
|
const needs=structuredClone(moved);needs.graph.artifacts[0].requires.sources={camera:{params:[],result:'String'}};
|
|
const absent=classifyArtifacts(needs,installed);
|
|
const shader=structuredClone(moved);shader.graph.artifacts.push({name:'shaders/map.wgsl',requires:{gpuSurfaces:[{name:'map',interface:'v2'}]}});
|
|
const unsupported=classifyArtifacts(shader,installed);
|
|
const grants=structuredClone(moved);grants.graph.artifacts[0].requires.grantCeiling='net.fetch https://new/';
|
|
const refused=classifyArtifacts(grants,installed);
|
|
// A TypeScript bundle on a newer prelude (LLP 1108 D6 R2: `exactBodyFrom`, then
|
|
// `redirect`) is no bundle for a cohort whose prelude predates it, and is for one
|
|
// as new or newer: runtime 1 would follow a redirect a runtime-2 bundle refused.
|
|
const runtime=structuredClone(moved);runtime.graph.artifacts[0].requires.typescriptRuntime=2;
|
|
const prelude=n=>({...installed,compat:{...installed.compat,inputs:{...installed.compat.inputs,typescriptRuntime:n}}});
|
|
const olderPrelude=classifyArtifacts(runtime,installed), runtimeOne=classifyArtifacts(runtime,prelude(1));
|
|
const samePrelude=classifyArtifacts(runtime,prelude(2)), newerPrelude=classifyArtifacts(runtime,prelude(3));
|
|
result('a bundle needing a newer TypeScript runtime is refused by an older cohort only',
|
|
!olderPrelude.bundle&&olderPrelude.missing[0].includes('typescriptRuntime')&&!runtimeOne.bundle&&runtimeOne.missing[0].includes('runtime 2 or later; this cohort\'s is 1')
|
|
&&samePrelude.bundle&&newerPrelude.bundle, JSON.stringify({olderPrelude,runtimeOne,samePrelude,newerPrelude}));
|
|
// The TypeScript module pair runs only where the binary's TypeScript grants are exactly its own (js/src/paired.rs).
|
|
const pair=(grants)=>{const c=structuredClone(moved);c.graph.artifacts.push({name:'typescript/app.hbc',requires:{javascriptGrants:grants}});return c;};
|
|
const holding=g=>({...installed,compat:{...installed.compat,inputs:{...installed.compat.inputs,javascriptGrants:g}}});
|
|
const narrower=classifyArtifacts(pair('net.fetch https://x/\nnet.fetch https://y/'),holding('net.fetch https://x/'));
|
|
const wider=classifyArtifacts(pair('net.fetch https://x/'),holding('net.fetch https://x/\nnet.fetch https://y/'));
|
|
const exact=classifyArtifacts(pair('net.fetch https://x/'),holding('net.fetch https://x/'));
|
|
result('a TypeScript pair goes only to a binary holding exactly its grants',
|
|
!narrower.bundle&&narrower.missing[0].includes('typescript/app.hbc: javascriptGrants')&&!wider.bundle&&exact.bundle,
|
|
JSON.stringify({narrower,wider,exact}));
|
|
// An Android cohort takes a generation that needs no more than its APK was composed from, and only its own Hermes
|
|
// bytecode version (LLP 1030.006 D4, D4a).
|
|
const apk=({...installed,compat:{...installed.compat,inputs:{...installed.compat.inputs,hermesBytecode:96,
|
|
androidLinked:{uses:['fetch','dates'],permissions:['android.permission.READ_CALENDAR'],extensions:['swatch']}}}});
|
|
const android=(plan,module)=>{const c=structuredClone(moved);Object.assign(c.graph.artifacts[0].requires,plan);c.graph.artifacts.push({name:'typescript/app.hbc',requires:module});return c;};
|
|
const less=classifyArtifacts(android({androidUses:['fetch'],androidExtensions:[]},{hermesBytecode:96,androidPermissions:[]}),apk);
|
|
const capability=classifyArtifacts(android({androidUses:['fetch','camera'],androidExtensions:[]},{hermesBytecode:96,androidPermissions:[]}),apk);
|
|
const extension=classifyArtifacts(android({androidUses:[],androidExtensions:['pedometer']},{hermesBytecode:96,androidPermissions:[]}),apk);
|
|
const permission=classifyArtifacts(android({androidUses:[],androidExtensions:[]},{hermesBytecode:96,androidPermissions:['android.permission.WRITE_CALENDAR']}),apk);
|
|
const engine=classifyArtifacts(android({androidUses:[],androidExtensions:[]},{hermesBytecode:97,androidPermissions:[]}),apk);
|
|
result('an Android cohort refuses a generation beyond what its APK linked or of another bytecode version, and takes one that needs less',
|
|
less.bundle&&!capability.bundle&&capability.missing[0].includes('androidUses.camera')&&!extension.bundle&&extension.missing[0].includes('androidExtensions.pedometer')
|
|
&&!permission.bundle&&permission.missing[0].includes('WRITE_CALENDAR')&&!engine.bundle&&engine.missing[0].includes('Hermes bytecode 97'),
|
|
JSON.stringify({less,capability,extension,permission,engine}));
|
|
result('artifact graph separates binary changes, compatible older cohorts, and named missing dependencies',
|
|
same.binary&&same.bundle&&old.binary&&old.bundle&&old.warnings[0].includes('will run the old code')
|
|
&&!absent.bundle&&absent.missing[0].includes('sources.camera')&&!unsupported.bundle&&unsupported.missing[0].includes('gpuSurfaces.map')
|
|
&&!refused.bundle&&refused.missing[0].includes('https://new/')&&!classifyArtifacts(moved,null).bundle,
|
|
JSON.stringify({same,old,absent,unsupported,refused}));
|
|
}
|
|
// Signing-key creation is one exclusive filesystem operation. Two publishers
|
|
// racing for an id cannot both report a public half and silently replace the
|
|
// private half behind the first one's result.
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-keygen-'));
|
|
const run = () => new Promise((done) => {
|
|
const child = spawn(process.execPath, [DEPLOY, 'keygen', 'race', '--keys', dir, '--json']);
|
|
let stdout = '', stderr = '';
|
|
child.stdout.on('data', (bytes) => { stdout += bytes; });
|
|
child.stderr.on('data', (bytes) => { stderr += bytes; });
|
|
child.on('close', (code) => done({ code, stdout, stderr }));
|
|
});
|
|
const attempts = await Promise.all([run(), run()]);
|
|
const winners = attempts.filter((attempt) => attempt.code === 0);
|
|
const losers = attempts.filter((attempt) => attempt.code !== 0);
|
|
let matches = false;
|
|
try {
|
|
const reported = Buffer.from(JSON.parse(winners[0]?.stdout ?? '{}').publicKey ?? '', 'base64');
|
|
const actual = createPublicKey(createPrivateKey(readFileSync(join(dir, 'race.pem'))))
|
|
.export({ type: 'spki', format: 'der' }).subarray(-32);
|
|
matches = reported.equals(actual);
|
|
} catch { /* the result below names the failed invariant */ }
|
|
result('concurrent keygen has one truthful winner', winners.length === 1 && losers.length === 1
|
|
&& losers[0].stderr.includes('a signing key is never overwritten') && matches,
|
|
attempts.map((attempt) => `exit ${attempt.code}: ${attempt.stdout}${attempt.stderr}`).join('\n'));
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
{ // Edit diagnostics own and remove large captured Git trees, including on callback failure.
|
|
const app = resolveApp('caltrain'), previous = process.env.CARGO_TARGET_DIR;
|
|
const traceDir = mkdtempSync(join(tmpdir(), 'exact-diagnostic-git-')), trace = join(traceDir, 'trace.json');
|
|
const previousTrace = process.env.GIT_TRACE2_EVENT;
|
|
writeFileSync(trace, '');
|
|
process.env.GIT_TRACE2_EVENT = trace;
|
|
let gitStarts;
|
|
let source, run, isolated = false, installed = false, caught = false, why = '';
|
|
try {
|
|
await withAppFixture(app, async f => {
|
|
source = f.sourceRoot; run = f.run;
|
|
isolated = f.app.dir !== app.dir && f.env.EXACT_APP_DIR === f.app.dir
|
|
&& f.env.CARGO_TARGET_DIR.startsWith(run + '/') && f.env.EXACT_WEB_DIST.startsWith(source + '/')
|
|
&& spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: f.app.dir, env: f.env, encoding: 'utf8' }).stdout.trim() === source;
|
|
// The capture carries the pinned node_modules (a TypeScript bake runs
|
|
// Rolldown from it), installed as an output, not committed as source.
|
|
installed = existsSync(join(f.exactRoot, 'node_modules/.bin/rolldown'))
|
|
&& spawnSync('git', ['ls-files', 'node_modules'], { cwd: f.exactRoot, env: f.env, encoding: 'utf8' }).stdout === '';
|
|
f.git(['commit', '--allow-empty', '-qm', 'Diagnostic follow-up']);
|
|
writeFileSync(join(f.app.dir, 'diagnostic-private.txt'), 'captured only');
|
|
for (let i = 0; i < 5000; i++) writeFileSync(join(source, `cleanup-${i}`), 'private');
|
|
throw new Error('expected diagnostic callback failure');
|
|
});
|
|
} catch (error) { caught = error.message === 'expected diagnostic callback failure'; why = caught ? '' : error.message; }
|
|
finally {
|
|
if (previousTrace === undefined) delete process.env.GIT_TRACE2_EVENT;
|
|
else process.env.GIT_TRACE2_EVENT = previousTrace;
|
|
try { gitStarts = readFileSync(trace, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)).filter(event => event.event === 'start'); }
|
|
finally { rmSync(traceDir, { recursive: true, force: true }); }
|
|
}
|
|
// Check invocation, not whether a detached collector happens to beat cleanup.
|
|
result('diagnostic Git commits do not start automatic maintenance',
|
|
gitStarts.filter(event => event.argv.includes('commit')).length === 2
|
|
&& !gitStarts.some(event => event.argv.includes('maintenance') || event.argv.includes('gc')),
|
|
gitStarts.map(event => event.argv.join(' ')).join('\n'));
|
|
// A fixture that could not start (a cold Cargo cache offline: run `cargo fetch`) says so.
|
|
result('diagnostic source and outputs are isolated and cleaned on failure', isolated && installed && caught
|
|
&& !existsSync(source ?? '/missing') && !existsSync(run ?? '/missing')
|
|
&& !existsSync(join(app.dir, 'diagnostic-private.txt')) && process.env.CARGO_TARGET_DIR === previous, why);
|
|
}
|
|
|
|
// A release remains recognizable to a person without being the bake's lock
|
|
// or directory identity. Even an explicitly reused correlation id gets a
|
|
// separate stage, and generated ids in the same clock tick do not collide.
|
|
{
|
|
const fixture = mkdtempSync(join(tmpdir(), 'exact-source-snapshot-'));
|
|
const compiler = join(fixture, 'contract-stub');
|
|
writeFileSync(compiler, '#!/usr/bin/env bun\nconsole.log(JSON.stringify({packages:[],sources:[{origin:"app",path:process.argv.at(-1)}],consulted:[],errors:[]}));\n');
|
|
chmodSync(compiler, 0o755);
|
|
const snapshots = [];
|
|
const snapshotOf = (app, opts, exactRoot) => { const snapshot = capturedSnapshotOf(app, { ...opts, compiler }, exactRoot); snapshots.push(snapshot); return snapshot; };
|
|
const init = (repo, files) => {
|
|
mkdirSync(repo, { recursive: true });
|
|
spawnSync('git', ['init', '-q'], { cwd: repo });
|
|
for (const [name, bytes] of Object.entries(files)) {
|
|
mkdirSync(dirname(join(repo, name)), { recursive: true });
|
|
writeFileSync(join(repo, name), bytes);
|
|
}
|
|
spawnSync('git', ['add', '-A'], { cwd: repo });
|
|
return spawnSync('git', ['-c', 'user.name=Exact Test', '-c', '[email protected]', 'commit', '-qm', 'fixture'], { cwd: repo }).status === 0;
|
|
};
|
|
const internal = join(fixture, 'internal');
|
|
const external = join(fixture, 'external');
|
|
const exact = join(fixture, 'exact2');
|
|
const cargoDep = join(fixture, 'cargo-dep');
|
|
const linked = join(fixture, 'linked');
|
|
const filtered = join(fixture, 'filtered');
|
|
const outsideLink = join(fixture, 'outside-link.rs');
|
|
const outsideTarget = join(fixture, 'outside-main.rs');
|
|
let initialized = init(internal, { '.gitignore': '/apps/test/assets/ignored.txt\n/generated/\n/target/\n',
|
|
'apps/test/app.contract': 'app Test\n', 'apps/test/assets/dist/published.txt': 'nested old\n',
|
|
'host/runtime.rs': 'old\n', 'removed.txt': 'remove me\n' })
|
|
&& init(cargoDep, { '.gitignore': '/crates/fixture-dep/ignored.rs\n/generated/\n/target/\n',
|
|
'Cargo.toml': '[workspace]\nmembers=["crates/fixture-dep"]\nresolver="2"\n[workspace.package]\nversion="0.1.0"\nedition="2021"\n',
|
|
'shared.txt': '1\n',
|
|
'generated/value.txt': 'ignored repository input\n',
|
|
'crates/fixture-dep/Cargo.toml': '[package]\nname="fixture-dep"\nversion.workspace=true\nedition.workspace=true\n',
|
|
'crates/fixture-dep/src/lib.rs': 'pub fn value() -> &\'static str { include_str!("../../../shared.txt").trim() }\n' })
|
|
&& init(external, { 'app.contract': 'app External\n',
|
|
'Cargo.toml': '[package]\nname="external"\nversion="0.1.0"\nedition="2021"\n[dependencies]\nfixture-dep={path="../cargo-dep/crates/fixture-dep"}\n',
|
|
'src/lib.rs': 'pub fn value() -> &\'static str { fixture_dep::value() }\n',
|
|
'src/main.rs': 'fn main() { println!("{}:{}", fixture_dep::value(), option_env!("RACE_VALUE").unwrap_or("sealed")); }\n' })
|
|
&& init(exact, { 'host/runtime.rs': 'old\n' })
|
|
&& init(linked, { 'app.contract': 'app Linked\n', 'src/main.rs': 'local\n' })
|
|
&& init(filtered, { 'app.contract': 'app Filtered\n', 'source.txt': 'WORKING SOURCE BYTES\n',
|
|
'target-one.txt': 'ONE\n', 'target-two.txt': 'TWO\n' });
|
|
if (initialized) {
|
|
writeFileSync(outsideLink, 'external mutable bytes\n');
|
|
writeFileSync(outsideTarget, 'fn main() { println!("live"); }\n');
|
|
rmSync(join(linked, 'src/main.rs'));
|
|
symlinkSync(outsideLink, join(linked, 'src/main.rs'));
|
|
spawnSync('git', ['add', 'src/main.rs'], { cwd: linked });
|
|
initialized = spawnSync('git', ['-c', 'user.name=Exact Test', '-c', '[email protected]',
|
|
'commit', '-qm', 'link'], { cwd: linked }).status === 0;
|
|
}
|
|
if (initialized) {
|
|
spawnSync('git', ['config', 'filter.worktree.clean', 'sed s/WORKING/STORED/g'], { cwd: filtered });
|
|
spawnSync('git', ['config', 'filter.worktree.smudge', 'sed s/STORED/WORKING/g'], { cwd: filtered });
|
|
spawnSync('git', ['config', 'filter.worktree.required', 'true'], { cwd: filtered });
|
|
writeFileSync(join(filtered, '.gitattributes'), 'source.txt filter=worktree\n');
|
|
spawnSync('git', ['add', '--renormalize', '.'], { cwd: filtered });
|
|
spawnSync('git', ['add', '.gitattributes'], { cwd: filtered });
|
|
initialized = spawnSync('git', ['-c', 'user.name=Exact Test', '-c', '[email protected]',
|
|
'commit', '-qm', 'filter'], { cwd: filtered }).status === 0;
|
|
}
|
|
if (initialized) {
|
|
symlinkSync('target-one.txt', join(filtered, 'alias.txt'));
|
|
symlinkSync(join(realpathSync(filtered), 'alias.txt'), join(filtered, 'inside.txt'));
|
|
spawnSync('git', ['add', 'alias.txt', 'inside.txt'], { cwd: filtered });
|
|
initialized = spawnSync('git', ['-c', 'user.name=Exact Test', '-c', '[email protected]',
|
|
'commit', '-qm', 'internal absolute link'], { cwd: filtered }).status === 0;
|
|
}
|
|
if (initialized) {
|
|
initialized = spawnSync('cargo', ['generate-lockfile'], { cwd: external, stdio: 'ignore' }).status === 0;
|
|
spawnSync('git', ['add', 'Cargo.lock'], { cwd: external });
|
|
initialized = initialized && spawnSync('git', ['-c', 'user.name=Exact Test', '-c', '[email protected]',
|
|
'commit', '-qm', 'lock'], { cwd: external }).status === 0;
|
|
}
|
|
const fixtureApp = (name, dir, workspace) => ({ name, dir, workspace, target: join(workspace, 'target'),
|
|
crate: (kind) => `${name}-${kind}`, manifest: { app: { id: `com.exact.${name}`, name }, host: {}, deploy: {} },
|
|
id: `com.exact.${name}`, displayName: name, origin: null, declared: false });
|
|
const internalApp = fixtureApp('test', join(internal, 'apps/test'), internal);
|
|
const externalApp = fixtureApp('external', external, external);
|
|
const linkedApp = fixtureApp('linked', linked, linked);
|
|
const filteredApp = fixtureApp('filtered', filtered, filtered);
|
|
const escapingSymlinkRefused = await rejects(() => snapshotOf(linkedApp, {}, linked), error => error.message.includes('symlink src/main.rs') && error.message.includes('absolute'));
|
|
const linkRaceBin = join(fixture, 'link-race-bin');
|
|
const linkRaceDone = join(fixture, 'link-race-done');
|
|
mkdirSync(linkRaceBin);
|
|
const linkRaceGit = spawnSync('which', ['git'], { encoding: 'utf8' }).stdout.trim();
|
|
writeFileSync(join(linkRaceBin, 'git'), `#!/bin/sh\n"${linkRaceGit}" "$@"\ncode=$?\ncase " $* " in\n *" write-tree "*)\n if [ ! -e "$EXACT_LINK_RACE_DONE" ]; then\n ln -shf target-two.txt "$EXACT_LINK_RACE_ALIAS"\n : > "$EXACT_LINK_RACE_DONE"\n fi\n ;;\nesac\nexit "$code"\n`);
|
|
chmodSync(join(linkRaceBin, 'git'), 0o755);
|
|
const linkRacePath = process.env.PATH;
|
|
process.env.PATH = `${linkRaceBin}:${linkRacePath}`;
|
|
process.env.EXACT_LINK_RACE_DONE = linkRaceDone;
|
|
process.env.EXACT_LINK_RACE_ALIAS = join(filtered, 'alias.txt');
|
|
let filteredSnapshot;
|
|
try { filteredSnapshot = snapshotOf(filteredApp, {}, filtered); }
|
|
finally {
|
|
process.env.PATH = linkRacePath;
|
|
delete process.env.EXACT_LINK_RACE_DONE;
|
|
delete process.env.EXACT_LINK_RACE_ALIAS;
|
|
rmSync(join(filtered, 'alias.txt'));
|
|
symlinkSync('target-one.txt', join(filtered, 'alias.txt'));
|
|
}
|
|
const filteredMaterialized = materializeSnapshot(filteredSnapshot, deployRun(filteredApp.target, 'filtered'), filteredApp);
|
|
const checkoutFilterPreserved = filteredSnapshot.id === filteredSnapshot.commit
|
|
&& readFileSync(join(filteredMaterialized.app.dir, 'source.txt'), 'utf8') === 'WORKING SOURCE BYTES\n'
|
|
&& !isAbsolute(readlinkSync(join(filteredMaterialized.app.dir, 'inside.txt')))
|
|
&& readFileSync(join(filteredMaterialized.app.dir, 'inside.txt'), 'utf8') === 'ONE\n';
|
|
const linkedTarget = join(fixture, 'shared-target');
|
|
mkdirSync(linkedTarget);
|
|
symlinkSync(linkedTarget, join(internal, 'target'));
|
|
const projectTmp = join(internal, 'project-tmp');
|
|
mkdirSync(projectTmp);
|
|
const priorTmp = process.env.TMPDIR;
|
|
process.env.TMPDIR = projectTmp;
|
|
// Staged additions deleted from disk are absent from the captured worktree;
|
|
// they must neither resurrect index bytes nor make git add fail.
|
|
writeFileSync(join(internal, 'staged-missing.txt'), 'never captured');
|
|
spawnSync('git', ['add', 'staged-missing.txt'], { cwd: internal });
|
|
rmSync(join(internal, 'staged-missing.txt'));
|
|
const realIndex = spawnSync('git', ['ls-files', '--stage', '-z'], { cwd: internal }).stdout;
|
|
let internalSnapshot;
|
|
try { internalSnapshot = snapshotOf(internalApp, {}, internal); }
|
|
finally {
|
|
if (priorTmp === undefined) delete process.env.TMPDIR;
|
|
else process.env.TMPDIR = priorTmp;
|
|
}
|
|
rmSync(join(internal, 'target'));
|
|
rmSync(projectTmp, { recursive: true });
|
|
// This is the race a before/after fingerprint cannot see: a transient edit
|
|
// exists while the bake is being prepared, then the checkout is restored.
|
|
writeFileSync(join(internal, 'host/runtime.rs'), 'transient during bake\n');
|
|
const cleanRun = deployRun(internalApp.target, 'clean-race');
|
|
const cleanMaterialized = materializeSnapshot(internalSnapshot, cleanRun, internalApp);
|
|
const cleanRaceBytes = readFileSync(join(cleanMaterialized.exactRoot, 'host/runtime.rs'), 'utf8');
|
|
result('capture omits missing staged additions and preserves the real index', !existsSync(join(cleanMaterialized.exactRoot, 'staged-missing.txt'))
|
|
&& realIndex.equals(spawnSync('git', ['ls-files', '--stage', '-z'], { cwd: internal }).stdout));
|
|
spawnSync('git', ['reset', '--', 'staged-missing.txt'], { cwd: internal });
|
|
const projectTmpRejected = relative(internal, cleanMaterialized.sourceRoot).startsWith('..');
|
|
writeFileSync(join(internal, 'host/runtime.rs'), 'old\n');
|
|
// Restore the live file immediately after the capture freezes its tree. A
|
|
// later live status read must not relabel those already-captured bytes as a
|
|
// clean HEAD snapshot.
|
|
const raceBin = join(fixture, 'race-bin');
|
|
const raceDone = join(fixture, 'race-done');
|
|
mkdirSync(raceBin);
|
|
const realGit = spawnSync('which', ['git'], { encoding: 'utf8' }).stdout.trim();
|
|
writeFileSync(join(raceBin, 'git'), `#!/bin/sh\n"${realGit}" "$@"\ncode=$?\ncase " $* " in\n *" write-tree "*|*" --binary "*)\n if [ ! -e "$EXACT_CAPTURE_RACE_DONE" ]; then\n printf 'old\\n' > "$EXACT_CAPTURE_RACE_FILE"\n : > "$EXACT_CAPTURE_RACE_DONE"\n fi\n ;;\nesac\nexit "$code"\n`);
|
|
chmodSync(join(raceBin, 'git'), 0o755);
|
|
writeFileSync(join(internal, 'host/runtime.rs'), 'transient during capture\n');
|
|
const oldPath = process.env.PATH;
|
|
process.env.PATH = `${raceBin}:${oldPath}`;
|
|
process.env.EXACT_CAPTURE_RACE_FILE = join(internal, 'host/runtime.rs');
|
|
process.env.EXACT_CAPTURE_RACE_DONE = raceDone;
|
|
let racedSnapshot;
|
|
try { racedSnapshot = snapshotOf(internalApp, { dirty: true }, internal); }
|
|
finally {
|
|
process.env.PATH = oldPath;
|
|
delete process.env.EXACT_CAPTURE_RACE_FILE;
|
|
delete process.env.EXACT_CAPTURE_RACE_DONE;
|
|
}
|
|
const racedMaterialized = materializeSnapshot(racedSnapshot, deployRun(internalApp.target, 'atomic-race'), internalApp);
|
|
const atomicRaceCaptured = racedSnapshot.dirty && racedSnapshot.id !== racedSnapshot.commit
|
|
&& racedSnapshot.changes.some((change) => change.endsWith('host/runtime.rs'))
|
|
&& readFileSync(join(racedMaterialized.exactRoot, 'host/runtime.rs'), 'utf8') === 'transient during capture\n'
|
|
&& readFileSync(join(internal, 'host/runtime.rs'), 'utf8') === 'old\n';
|
|
writeFileSync(join(internal, 'host/runtime.rs'), 'edited\n');
|
|
writeFileSync(join(internal, 'apps/test/assets/dist/published.txt'), 'nested edited\n');
|
|
writeFileSync(join(internal, 'apps/test/assets/ignored.txt'), 'ignored captured\n');
|
|
rmSync(join(internal, 'removed.txt'));
|
|
mkdirSync(join(internal, 'target'), { recursive: true });
|
|
writeFileSync(join(internal, 'target/generated.bin'), 'not source\n');
|
|
mkdirSync(join(internal, 'generated'));
|
|
writeFileSync(join(internal, 'generated/output.bin'), 'ignored input root\n');
|
|
const siblingRefused = await rejects(() => snapshotOf(internalApp, {}, internal), error => error.message.includes('exact2') && error.message.includes('host/runtime.rs')
|
|
&& error.message.includes('assets/ignored.txt') && !error.message.includes('target/generated.bin'));
|
|
const dirtySnapshot = snapshotOf(internalApp, { dirty: true }, internal);
|
|
writeFileSync(join(internal, 'host/runtime.rs'), 'transient replacement\n');
|
|
writeFileSync(join(internal, 'apps/test/assets/dist/published.txt'), 'transient nested replacement\n');
|
|
writeFileSync(join(internal, 'apps/test/assets/ignored.txt'), 'transient ignored replacement\n');
|
|
const dirtyRun = deployRun(internalApp.target, 'dirty-race');
|
|
const dirtyMaterialized = materializeSnapshot(dirtySnapshot, dirtyRun, internalApp);
|
|
writeFileSync(join(internal, 'host/runtime.rs'), 'edited\n');
|
|
writeFileSync(join(internal, 'apps/test/assets/dist/published.txt'), 'nested edited\n');
|
|
writeFileSync(join(internal, 'apps/test/assets/ignored.txt'), 'ignored captured\n');
|
|
const capturedDirtyBytes = readFileSync(join(dirtyMaterialized.exactRoot, 'host/runtime.rs'), 'utf8') === 'edited\n'
|
|
&& readFileSync(join(dirtyMaterialized.app.dir, 'assets/dist/published.txt'), 'utf8') === 'nested edited\n'
|
|
&& readFileSync(join(dirtyMaterialized.app.dir, 'assets/ignored.txt'), 'utf8') === 'ignored captured\n'
|
|
&& !existsSync(join(dirtyMaterialized.exactRoot, 'removed.txt'))
|
|
&& readFileSync(join(dirtyMaterialized.exactRoot, 'generated/output.bin'), 'utf8') === 'ignored input root\n';
|
|
writeFileSync(join(internal, 'apps/test/assets/ignored.txt'), 'ignored changed\n');
|
|
const dirtyAgain = snapshotOf(internalApp, { dirty: true }, internal);
|
|
const changedSnapshotRefused = await rejects(() => snapshotOf(internalApp, { dirty: true, snapshot: dirtySnapshot.id }, internal), error => error.message.includes('same complete source set'));
|
|
const ignoredRootRefused = await rejects(() => snapshotOf(externalApp, {}, exact), error => error.message.includes('cargo') && error.message.includes('generated/value.txt'));
|
|
const externalSnapshot = snapshotOf(externalApp, { dirty: true }, exact);
|
|
const partialPinRefused = await rejects(() => snapshotOf(externalApp, { dirty: true, snapshot: externalSnapshot.commit }, exact), error => error.message.includes('complete source set'));
|
|
const pinned = snapshotOf(externalApp, { dirty: true, snapshot: externalSnapshot.id.slice(0, 12) }, exact);
|
|
const externalRun = deployRun(externalApp.target, 'external');
|
|
const externalMaterialized = materializeSnapshot(externalSnapshot, externalRun, externalApp);
|
|
const stagedGitProbe = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
cwd: externalMaterialized.exactRoot, encoding: 'utf8',
|
|
});
|
|
const stagedGitSealed = stagedGitProbe.status !== 0 && stagedGitProbe.stdout.trim() === '';
|
|
const ignoredRootCaptured = readFileSync(join(dirname(externalMaterialized.app.dir), 'cargo-dep/generated/value.txt'), 'utf8')
|
|
=== 'ignored repository input\n';
|
|
writeFileSync(join(cargoDep, 'shared.txt'), '2\n');
|
|
mkdirSync(join(external, '.cargo'));
|
|
writeFileSync(join(external, '.cargo/config.toml'), '[env]\nRACE_VALUE="live ancestor"\n');
|
|
const stagedRun = spawnSync('cargo', ['run', '--quiet', '--locked'], { cwd: externalMaterialized.app.workspace,
|
|
env: { ...process.env, CARGO_TARGET_DIR: join(fixture, 'cargo-target') }, encoding: 'utf8' });
|
|
const stagedDependencyStayedCaptured = stagedRun.status === 0 && stagedRun.stdout.trim() === '1:sealed';
|
|
rmSync(join(external, '.cargo'), { recursive: true });
|
|
writeFileSync(join(cargoDep, 'shared.txt'), '1\n');
|
|
// Cargo accepts absolute path dependencies, but an immutable deploy cannot:
|
|
// the staged manifest would otherwise reach back into the mutable checkout.
|
|
const relativeManifest = readFileSync(join(external, 'Cargo.toml'), 'utf8');
|
|
writeFileSync(join(external, 'Cargo.toml'), relativeManifest.replace('../cargo-dep/crates/fixture-dep', join(cargoDep, 'crates/fixture-dep')));
|
|
const absoluteSnapshot = snapshotOf(externalApp, { dirty: true }, exact);
|
|
writeFileSync(join(cargoDep, 'shared.txt'), '2\n');
|
|
const absoluteDependencyRefused = await rejects(() => materializeSnapshot(absoluteSnapshot, deployRun(externalApp.target, 'absolute-dependency'), externalApp), error => error.message.includes('resolves outside the captured source root'));
|
|
writeFileSync(join(cargoDep, 'shared.txt'), '1\n');
|
|
writeFileSync(join(external, 'Cargo.toml'), relativeManifest);
|
|
writeFileSync(join(external, 'Cargo.toml'), `${relativeManifest}\n[[bin]]\nname="outside"\npath=${JSON.stringify(outsideTarget)}\n`);
|
|
const absoluteTargetSnapshot = snapshotOf(externalApp, { dirty: true }, exact);
|
|
const absoluteTargetRefused = await rejects(() => materializeSnapshot(absoluteTargetSnapshot, deployRun(externalApp.target, 'absolute-target'), externalApp), error => error.message.includes('target outside') && error.message.includes('resolves outside'));
|
|
writeFileSync(join(external, 'Cargo.toml'), relativeManifest);
|
|
writeFileSync(join(cargoDep, 'crates/fixture-dep/ignored.rs'), 'pub const CAPTURED: bool = true;\n');
|
|
const ignoredDependencyRefused = await rejects(() => snapshotOf(externalApp, {}, exact), error => error.message.includes('cargo') && error.message.includes('crates/fixture-dep/ignored.rs'));
|
|
const dependencyDirty = snapshotOf(externalApp, { dirty: true }, exact);
|
|
const dependencyRun = deployRun(externalApp.target, 'external-dependency');
|
|
const dependencyMaterialized = materializeSnapshot(dependencyDirty, dependencyRun, externalApp);
|
|
const capturedDependency = readFileSync(join(dirname(dependencyMaterialized.app.dir), 'cargo-dep/crates/fixture-dep/ignored.rs'), 'utf8')
|
|
=== 'pub const CAPTURED: bool = true;\n';
|
|
rmSync(join(cargoDep, 'crates/fixture-dep/ignored.rs'));
|
|
const rendered = renderTable({ release: 'test', snapshot: externalSnapshot, channel: 'prod',
|
|
origin: { kind: 'directory', location: '/origin' }, notes: [], rows: [] });
|
|
writeFileSync(join(exact, 'host/runtime.rs'), 'edited\n');
|
|
const dependencyRefused = await rejects(() => snapshotOf(externalApp, {}, exact), error => error.message.includes('exact2') && error.message.includes('host/runtime.rs'));
|
|
result('deploy snapshots every source repository the bake reads', initialized
|
|
&& internalSnapshot.id === internalSnapshot.commit && internalSnapshot.sources.length === 1
|
|
&& internalSnapshot.sources[0].roles.join(',') === 'app,exact2'
|
|
&& cleanRaceBytes === 'old\n' && projectTmpRejected && checkoutFilterPreserved && atomicRaceCaptured
|
|
&& siblingRefused && dirtySnapshot.dirty && dirtySnapshot.id !== dirtyAgain.id
|
|
&& capturedDirtyBytes && changedSnapshotRefused
|
|
&& dirtySnapshot.changes.some((change) => change.endsWith('host/runtime.rs'))
|
|
&& dirtySnapshot.changes.some((change) => change.endsWith('apps/test/assets/dist/published.txt'))
|
|
&& dirtySnapshot.changes.some((change) => change.endsWith('apps/test/assets/ignored.txt'))
|
|
&& dirtySnapshot.changes.some((change) => change.endsWith('removed.txt'))
|
|
&& !dirtySnapshot.changes.some((change) => change.includes('target/generated.bin'))
|
|
&& dirtySnapshot.changes.some((change) => change.includes('generated/output.bin'))
|
|
&& /^[0-9a-f]{40}$/.test(externalSnapshot.id) && externalSnapshot.id !== externalSnapshot.commit
|
|
&& externalSnapshot.sources.length === 3 && partialPinRefused && pinned.id === externalSnapshot.id
|
|
&& relative(externalMaterialized.app.dir, externalMaterialized.exactRoot) === '../exact2'
|
|
&& stagedDependencyStayedCaptured && absoluteDependencyRefused && absoluteTargetRefused
|
|
&& stagedGitSealed && escapingSymlinkRefused
|
|
&& ignoredRootRefused && ignoredRootCaptured
|
|
&& ignoredDependencyRefused && capturedDependency
|
|
&& rendered.includes(`snapshot ${externalSnapshot.id.slice(0, 12)}`)
|
|
&& dependencyRefused,
|
|
JSON.stringify({ initialized, internalSnapshot, cleanRaceBytes, projectTmpRejected, checkoutFilterPreserved, atomicRaceCaptured,
|
|
siblingRefused, dirtySnapshot, dirtyAgain: dirtyAgain.id,
|
|
capturedDirtyBytes, changedSnapshotRefused, externalSnapshot, partialPinRefused, pinned: pinned.id,
|
|
externalLayout: relative(externalMaterialized.app.dir, externalMaterialized.exactRoot),
|
|
stagedDependencyStayedCaptured, absoluteDependencyRefused, absoluteTargetRefused,
|
|
stagedGitSealed, escapingSymlinkRefused,
|
|
ignoredRootRefused, ignoredRootCaptured,
|
|
ignoredDependencyRefused, capturedDependency, dependencyRefused }));
|
|
for (const snapshot of snapshots) disposeSnapshot(snapshot);
|
|
rmSync(fixture, { recursive: true, force: true });
|
|
}
|
|
{
|
|
const target = mkdtempSync(join(tmpdir(), 'exact-deploy-run-'));
|
|
const now = new Date('2026-09-04T12:34:56.789Z');
|
|
const ids = new Set(Array.from({ length: 32 }, () => defaultRelease('a'.repeat(40), now)));
|
|
const first = deployRun(target, 'same-release');
|
|
writeFileSync(join(first, 'still-here'), 'first');
|
|
const second = deployRun(target, 'same-release');
|
|
result('deploy ids and private stages do not collide in one clock tick', ids.size === 32
|
|
&& first !== second && readFileSync(join(first, 'still-here'), 'utf8') === 'first');
|
|
rmSync(target, { recursive: true, force: true });
|
|
}
|
|
// Stream heads point only at immutable blobs. Their immutable audit record is
|
|
// prepared before the conditional head swap; a failed record cannot expose a
|
|
// head, and a failed head leaves the preceding one and every named blob whole.
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-stream-commit-'));
|
|
const origin = new DirectoryOrigin(dir);
|
|
const compatibilityId = 'c'.repeat(32);
|
|
const row = { kind: 'stream', platform: 'linux', channel: 'prod', compatibilityId,
|
|
action: 'bundle', changes: [{ name: 'app.plan', change: 'new' }, { name: 'assets/icon.png', change: 'new' }] };
|
|
const plan = Buffer.from('plan bytes');
|
|
const asset = Buffer.from('asset bytes');
|
|
const bundle = { plan: { bytes: plan, sha256: createHash('sha256').update(plan).digest('hex'), formatVersion: 4, kernelSchema: '0'.repeat(16) },
|
|
assets: [{ name: 'assets/icon.png', bytes: asset, sha256: createHash('sha256').update(asset).digest('hex') }] };
|
|
const app = { id: 'com.exact.test', displayName: 'Test', manifest: { deploy: {} } };
|
|
const signer = { keyId: 'test', sign: () => ({ keyId: 'test', ed25519: Buffer.alloc(64).toString('base64') }) };
|
|
const racedPath = blobPath('d'.repeat(64));
|
|
const raced = await Promise.allSettled([
|
|
origin.put(racedPath, Buffer.from('first'), { immutable: true }),
|
|
origin.put(racedPath, Buffer.from('second'), { immutable: true }),
|
|
]);
|
|
const racedBytes = await origin.get(racedPath);
|
|
const immutableRaceHeld = raced.filter((attempt) => attempt.status === 'fulfilled').length === 1
|
|
&& raced.filter((attempt) => attempt.status === 'rejected').length === 1
|
|
&& (racedBytes.equals(Buffer.from('first')) || racedBytes.equals(Buffer.from('second')));
|
|
const order = [];
|
|
const put = origin.put.bind(origin);
|
|
origin.put = async (rel, bytes, options) => { const result = await put(rel, bytes, options); order.push(`put ${rel}`); return result; };
|
|
const putHead = origin.putHead.bind(origin);
|
|
origin.putHead = async (...args) => { order.push('put head'); return putHead(...args); };
|
|
const args = { origin, row, bundle, compat: { id: compatibilityId, inputs: {} }, app, signer,
|
|
release: 'one', snapshot: { commit: '0'.repeat(40), dirty: false, changes: [] }, opts: {}, log: () => {} };
|
|
await publishStream(args);
|
|
const stream = { channel: 'prod', compatibilityId };
|
|
const head = await origin.head(stream);
|
|
const base = `.exact/prod/${compatibilityId}`;
|
|
const recordPath = `${base}/releases/one.json`;
|
|
const record = JSON.parse((await origin.get(recordPath)).toString('utf8'));
|
|
let reusedRefused = false;
|
|
const beforeRetry = order.length;
|
|
try { await publishStream(args); }
|
|
catch (error) { reusedRefused = error.message.includes('already has an immutable record'); }
|
|
const names = await origin.list(base);
|
|
// The head names the plan and the asset index as blobs; the index names the asset beside them.
|
|
const blobCards = [head.json.plan, head.json.assets];
|
|
const listed = readAssetIndex(head.json.assets, await origin.get(blobPath(head.json.assets.sha256)));
|
|
// An index the origin cannot serve now is an unavailable row, not a classification that throws.
|
|
const outage = Object.create(origin);
|
|
outage.get = async (rel) => { if (rel === blobPath(head.json.assets.sha256)) throw new OriginUnavailable('index: HTTP 503'); return origin.get(rel); };
|
|
const outageTable = await classify({ app, opts: { platform: [] }, origin: outage, channel: 'prod',
|
|
snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir }, release: 'next', web: dir, bundle,
|
|
builds: { linux: fixtureBuild(compatibilityId, bundle) }, compat: { linux: { id: compatibilityId, inputs: { store: { L: 'A' }, executors: [] } } },
|
|
platforms: ['linux'], wantOrigin: false });
|
|
const indexOutageUnavailable = outageTable.rows[0]?.action === 'unavailable' && outageTable.rows[0].reason.includes('HTTP 503');
|
|
const recordBeforeHead = order.indexOf(`put ${recordPath}`) < order.indexOf('put head');
|
|
const interrupted = [];
|
|
// Writes: the plan, index and asset blobs, the record, then the head.
|
|
for (const moment of ['before', 'after']) for (let stop = 1; stop <= 5; stop++) {
|
|
const failedDir = mkdtempSync(join(tmpdir(), `exact-stream-failure-${moment}-${stop}-`));
|
|
const failed = new DirectoryOrigin(failedDir);
|
|
const oldPlan = Buffer.from('old plan');
|
|
const oldBundle = { plan: { ...bundle.plan, bytes: oldPlan, sha256: createHash('sha256').update(oldPlan).digest('hex') }, assets: [] };
|
|
await failed.put(blobPath(oldBundle.plan.sha256), oldPlan, { immutable: true });
|
|
const oldHead = streamHead({ app, bundle: oldBundle, stream, seq: 1, release: 'old' });
|
|
oldHead.signature = signer.sign(oldHead);
|
|
const oldBytes = Buffer.from(JSON.stringify(oldHead) + '\n');
|
|
await failed.withLock(stream, () => failed.putHead(stream, oldBytes));
|
|
const rawPut = failed.put.bind(failed);
|
|
let writes = 0;
|
|
failed.put = async (...putArgs) => {
|
|
writes++;
|
|
if (writes === stop && moment === 'before') throw new Error(`injected before write ${stop}`);
|
|
const result = await rawPut(...putArgs);
|
|
if (writes === stop && moment === 'after') throw new Error(`injected after write ${stop}`);
|
|
return result;
|
|
};
|
|
const rawHead = failed.putHead.bind(failed);
|
|
failed.putHead = async (...headArgs) => {
|
|
writes++;
|
|
if (writes === stop && moment === 'before') throw new Error(`injected before write ${stop}`);
|
|
const result = await rawHead(...headArgs);
|
|
if (writes === stop && moment === 'after') throw new Error(`injected after write ${stop}`);
|
|
return result;
|
|
};
|
|
let stopped = false;
|
|
let result = null;
|
|
try { result = await publishStream({ ...args, origin: failed, release: `failed-${moment}-${stop}` }); }
|
|
catch (error) { stopped = error.message.includes('injected'); }
|
|
const after = await failed.head(stream);
|
|
interrupted.push((stop < 5
|
|
? stopped && after.bytes.equals(oldBytes)
|
|
: moment === 'before'
|
|
? stopped && after.bytes.equals(oldBytes)
|
|
: result?.action === 'published' && after.json.release === `failed-${moment}-${stop}`)
|
|
&& (await failed.get(blobPath(oldBundle.plan.sha256))).equals(oldPlan));
|
|
rmSync(failedDir, { recursive: true, force: true });
|
|
}
|
|
result('stream publication commits immutable blobs and receipt before its head', recordBeforeHead
|
|
&& names.join(',') === 'exact.json,releases' && blobCards.every((card) => card.url === `../../blobs/${card.sha256}`)
|
|
&& blobCards.every((card) => existsSync(join(dir, '.exact', 'blobs', card.sha256)))
|
|
&& listed.length === 1 && listed[0].url === `./${listed[0].sha256}` && existsSync(join(dir, '.exact', 'blobs', listed[0].sha256))
|
|
&& canonicalBytes(record.assets).equals(await origin.get(blobPath(head.json.assets.sha256)))
|
|
&& record.head.entryDigest === createHash('sha256').update(canonicalBytes(head.json)).digest('hex')
|
|
&& record.envelope.signature.keyId === 'test' && immutableRaceHeld && reusedRefused && order.length === beforeRetry
|
|
&& interrupted.every(Boolean) && indexOutageUnavailable, JSON.stringify(outageTable.rows[0]));
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
// All origin verbs reject links at the root, intermediate, and leaf boundary.
|
|
// Locks are permanent OS ownership, independent of age or claimed host/PID.
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-origin-handles-'));
|
|
const originDir = join(dir, 'origin');
|
|
const outside = join(dir, 'outside');
|
|
mkdirSync(originDir); mkdirSync(outside);
|
|
writeFileSync(join(outside, 'secret'), 'private');
|
|
const origin = new DirectoryOrigin(originDir);
|
|
const stream = { channel: 'prod', compatibilityId: 'test' };
|
|
const refusals = [];
|
|
const refuse = async (fn) => { try { await fn(); return false; } catch { return true; } };
|
|
symlinkSync(outside, join(originDir, 'escape'));
|
|
symlinkSync(join(outside, 'secret'), join(originDir, 'linked'));
|
|
symlinkSync(join(outside, 'absent'), join(originDir, 'dangling'));
|
|
refusals.push(await refuse(() => origin.get('escape/secret')),
|
|
await refuse(() => origin.list('escape')), await refuse(() => origin.get('linked')),
|
|
await refuse(() => origin.list('linked')), await refuse(() => origin.put('linked', Buffer.from('public'))),
|
|
await refuse(() => origin.put('dangling', Buffer.from('public'))),
|
|
await refuse(() => origin.put('escape/new/deep/file', Buffer.from('public'))));
|
|
symlinkSync(outside, join(originDir, '.exact'));
|
|
refusals.push(await refuse(() => origin.putHead(stream, Buffer.from('{}'))),
|
|
await refuse(() => origin.withLock(stream, async () => false)));
|
|
rmSync(join(originDir, '.exact'));
|
|
await origin.withLock(stream, async () => true);
|
|
const lockPath = join(originDir, '.exact/prod/test/.lock');
|
|
rmSync(lockPath);
|
|
symlinkSync(join(outside, 'secret'), lockPath);
|
|
refusals.push(await refuse(() => origin.withLock(stream, async () => false)));
|
|
rmSync(lockPath);
|
|
const headPath = join(originDir, '.exact/prod/test/exact.json');
|
|
symlinkSync(join(outside, 'secret'), headPath);
|
|
refusals.push(await refuse(() => origin.putHead(stream, Buffer.from('{}'))));
|
|
rmSync(headPath);
|
|
refusals.push(await refuse(() => copyStaticTreeIfPresent(join(originDir, 'escape/missing'), join(dir, 'static-candidate'))));
|
|
const rootLink = join(dir, 'root-link'); symlinkSync(originDir, rootLink);
|
|
refusals.push(await refuse(() => new DirectoryOrigin(rootLink).get('linked')),
|
|
await refuse(() => new DirectoryOrigin(rootLink).put('new', Buffer.from('public'))));
|
|
let agedHeld = false, otherHostHeld = false, replacedRefused = false, successorHeld = false;
|
|
const priorHost = process.env.HOSTNAME;
|
|
try {
|
|
process.env.HOSTNAME = 'publisher-a';
|
|
await origin.withLock(stream, async () => {
|
|
utimesSync(lockPath, new Date(0), new Date(0));
|
|
agedHeld = await refuse(() => new DirectoryOrigin(originDir).withLock(stream, async () => false));
|
|
process.env.HOSTNAME = 'publisher-b';
|
|
otherHostHeld = await refuse(() => new DirectoryOrigin(originDir).withLock(stream, async () => false));
|
|
renameSync(lockPath, join(dir, 'retired-lock'));
|
|
let entered;
|
|
const ready = new Promise((resolve) => { entered = resolve; });
|
|
let release;
|
|
const pending = new Promise((resolve) => { release = resolve; });
|
|
const successor = new DirectoryOrigin(originDir);
|
|
// Keep the successor held until the old owner's finally has run.
|
|
const held = successor.withLock(stream, async () => { entered(); await pending; });
|
|
await ready;
|
|
replacedRefused = await refuse(() => origin.putHead(stream, Buffer.from('{}')));
|
|
origin.successor = { held, release };
|
|
});
|
|
successorHeld = await refuse(() => new DirectoryOrigin(originDir).withLock(stream, async () => false));
|
|
origin.successor.release(); await origin.successor.held;
|
|
} finally {
|
|
if (priorHost === undefined) delete process.env.HOSTNAME; else process.env.HOSTNAME = priorHost;
|
|
}
|
|
const reusable = await origin.withLock(stream, async () => true);
|
|
await origin.putHead(stream, Buffer.from('{"seq":1}'));
|
|
const conditional = await refuse(() => origin.putHead(stream, Buffer.from('{"seq":2}')));
|
|
result('origin handles reject every linked boundary and retain exclusive lock ownership',
|
|
refusals.every(Boolean) && agedHeld && otherHostHeld && replacedRefused && successorHeld && reusable && conditional
|
|
&& readFileSync(join(outside, 'secret'), 'utf8') === 'private' && !existsSync(join(outside, 'new')),
|
|
JSON.stringify({ refusals, agedHeld, otherHostHeld, replacedRefused, successorHeld, reusable, conditional }));
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
// Asset names must stage as distinct files on every client filesystem, APFS
|
|
// included: the publisher refuses what exact-update's check_asset_names
|
|
// refuses, before a head is signed or admitted.
|
|
{
|
|
const refusals = [
|
|
[['Logo.png', 'logo.png'], /differ only by case/],
|
|
[['a', 'a/b.png'], /is a file where/],
|
|
[['deck/B/x.html', 'deck/b'], /is a file where/],
|
|
[['caf\u00e9.png'], /not portable/],
|
|
[['cafe\u0301.png'], /not portable/],
|
|
[['two words.png'], /not portable/],
|
|
[['mark.png', 'mark.png'], /twice/],
|
|
];
|
|
const refused = refusals.map(([names, why]) => { try { portableAssetNames(names); return false; } catch (error) { return why.test(error.message); } });
|
|
let accepted = true;
|
|
for (const names of [['a/b.png', 'a/c.png', 'A-b_c.1.png'], ['deck/index.html', 'deck/index.html.map'], ['[email protected]', '[email protected]']]) {
|
|
try { portableAssetNames(names); } catch { accepted = false; }
|
|
}
|
|
// Every refused name in one answer, one per line.
|
|
try { portableAssetNames(['two words.png', 'Logo.png', 'logo.png', 'ok.png', 'caf\u00e9.png']); accepted = false; }
|
|
catch (error) { const lines = error.message.split('\n'); if (lines.length !== 3 || !/two words/.test(lines[0]) || !/differ only by case/.test(lines[1]) || !/caf\u00e9/.test(lines[2])) accepted = false; }
|
|
const app = { id: 'com.exact.names', displayName: 'Names', manifest: {} };
|
|
const stream = { channel: 'prod', compatibilityId: 'a'.repeat(32) };
|
|
const planBytes = Buffer.from('plan');
|
|
const card = (name) => ({ name, bytes: Buffer.from(name), sha256: sha256(Buffer.from(name)) });
|
|
const bundle = { plan: { bytes: planBytes, sha256: sha256(planBytes), formatVersion: 4, kernelSchema: '0'.repeat(16) }, assets: [card('Logo.png'), card('logo.png')] };
|
|
const signingRefused = await rejects(() => streamHead({ app, bundle, stream, seq: 1, release: 'names' }), (error) => /differ only by case/.test(error.message));
|
|
// An index another publisher wrote with names that fold together is refused as it is read.
|
|
const listed = JSON.parse(streamIndex({ ...bundle, assets: [card('Logo.png')] }).bytes).assets;
|
|
const raw = canonicalBytes({ assets: [...listed, { ...listed[0], name: 'logo.png' }] });
|
|
let indexProblem = null;
|
|
try { readAssetIndex({ url: './x', sha256: sha256(raw), bytes: raw.length, count: 2 }, raw); } catch (error) { indexProblem = error.message; }
|
|
// A web root's index is read at any size (a browser loads it); a client's is bounded.
|
|
const huge = canonicalBytes({ assets: Array.from({ length: 34000 }, (_, i) => ({ ...listed[0], name: `assets/n${i}` })) });
|
|
const hugeCard = { url: './x', sha256: sha256(huge), bytes: huge.length, count: 34000 };
|
|
const unbounded = readAssetIndex(hugeCard, huge, { bounded: false }).length === 34000;
|
|
const bounded = await rejects(() => readAssetIndex(hugeCard, huge), (error) => /for 34000 assets; the most is 4096\.0 KiB/.test(error.message));
|
|
result('a web root reads an index over the client bound; a client refuses it', unbounded && bounded);
|
|
// `plans` is optional, as exact-update reads it: an index with a plan asset and no map is read.
|
|
const planned = canonicalBytes({ assets: [{ ...listed[0], name: 'plans/card.plan' }] });
|
|
let plansOptional = true;
|
|
try { readAssetIndex({ url: './x', sha256: sha256(planned), bytes: planned.length, count: 1 }, planned); } catch { plansOptional = false; }
|
|
result('an asset index without a plans map is read', plansOptional);
|
|
result('the publisher refuses asset names a client filesystem folds together', refused.every(Boolean) && accepted
|
|
&& signingRefused && /differ only by case/.test(indexProblem ?? ''), JSON.stringify({ refused, accepted, signingRefused, indexProblem }));
|
|
}
|
|
|
|
// The next seq sits above everything the stream has authenticated: the head
|
|
// and every signed immutable release record. A head rolled back on the
|
|
// origin (a restored backup, a stale replica) must not hand out a seq
|
|
// clients already hold for another bundle.
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-seq-floor-'));
|
|
const compatibilityId = 'b'.repeat(32);
|
|
const stream = { channel: 'prod', compatibilityId };
|
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
const publicRaw = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('base64');
|
|
const app = { id: 'com.exact.seqfloor', displayName: 'Seq Floor', dir,
|
|
manifest: { deploy: { signing: { key: 'test', keys: { test: publicRaw } } } } };
|
|
const bundleOf = (text) => {
|
|
const plan = Buffer.from(text);
|
|
return { plan: { bytes: plan, sha256: sha256(plan), formatVersion: 4, kernelSchema: '0'.repeat(16) }, assets: [] };
|
|
};
|
|
const published = bundleOf('published plan'), next = bundleOf('next plan');
|
|
const signedAt = (seq) => {
|
|
const head = streamHead({ app, bundle: published, stream, seq, release: `r${seq}` });
|
|
head.cohort = cohortReceipt(fixtureBuild(compatibilityId, published));
|
|
head.signature = { keyId: 'test', ed25519: cryptoSign(null, canonicalBytes(head), privateKey).toString('base64') };
|
|
return head;
|
|
};
|
|
const head = signedAt(7);
|
|
const headBytes = Buffer.from(JSON.stringify(head) + '\n');
|
|
const records = { 'r7.json': signedAt(7), 'r9.json': signedAt(9) };
|
|
const tableWith = (names) => classify({ app, opts: { platform: [] },
|
|
origin: {
|
|
kind: 'directory', describe: () => dir,
|
|
get: async (name) => {
|
|
const leaf = name.split('/').at(-1);
|
|
return names.includes(leaf) ? Buffer.from(JSON.stringify({ envelope: records[leaf] })) : null;
|
|
},
|
|
head: async () => ({ json: head, bytes: headBytes, sha256: sha256(headBytes) }),
|
|
list: async (name) => name.endsWith('/releases') ? names : [],
|
|
},
|
|
channel: stream.channel, snapshot: { commit: '0'.repeat(40), dirty: false, changes: [], repo: dir },
|
|
release: 'next', web: dir, bundle: next, builds: { linux: fixtureBuild(compatibilityId, next) },
|
|
compat: { linux: { id: compatibilityId, inputs: { store: { L: 'A' }, executors: [] } } },
|
|
platforms: ['linux'], wantOrigin: false });
|
|
const rolledBack = await tableWith(['r7.json', 'r9.json']);
|
|
const inStep = await tableWith(['r7.json']);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
result('a rolled-back head allocates above the authenticated release history',
|
|
rolledBack.rows[0].action === 'bundle' && rolledBack.rows[0].seq === 10 && inStep.rows[0].seq === 8,
|
|
JSON.stringify({ rolledBack: rolledBack.rows[0], inStep: inStep.rows[0] }));
|
|
}
|
|
|
|
{
|
|
// LLP 1069.008 D7: the dry run ends with what the app can reach.
|
|
const reach = [
|
|
{ grant: 'net.fetch https://api.example.com/', purpose: null, enforced: 'runtime (all hosts); CSP (served web)' },
|
|
{ grant: 'device.microphone purpose.microphone', purpose: 'Records your takes.', enforced: 'OS prompt (iOS, macOS); Permissions-Policy (served web; a static dist sends none); declared by app.ts' },
|
|
];
|
|
const table = (rows) => renderTable({ release: 'test', snapshot: { commit: '0'.repeat(40), dirty: false }, channel: 'prod',
|
|
origin: { kind: 'directory', location: '/origin' }, notes: [], rows: [], reach: rows }).split('\n');
|
|
const shown = table(reach), none = table(null);
|
|
result('the dry run prints what the app can reach, with who enforces each line',
|
|
shown[1] === 'what this app can reach:' && /^ {2}reach +purpose +enforced by$/.test(shown[2])
|
|
&& /^ {2}net\.fetch https:\/\/api\.example\.com\/ +— +runtime \(all hosts\); CSP \(served web\)$/.test(shown[3])
|
|
&& shown[4].includes('"Records your takes."') && shown[4].endsWith('declared by app.ts')
|
|
&& none.length === 1,
|
|
shown.join('\n'));
|
|
}
|
|
|
|
{
|
|
const card = { name: 'plans/trains.plan', sha256: 'a'.repeat(64), bytes: Buffer.from('snapshot') };
|
|
const app = { id: 'test.widgets', displayName: 'Widgets' };
|
|
const bundle = { plan: { bytes: Buffer.from('app'), sha256: 'b'.repeat(64), formatVersion: 5, kernelSchema: '0'.repeat(16) }, assets: [card] };
|
|
const head = streamHead({ app, bundle, stream: { channel: 'prod', compatibilityId: 'c'.repeat(32) }, seq: 1, release: 'widgets' });
|
|
const index = JSON.parse(streamIndex(bundle).bytes);
|
|
result('entry plans index the signed asset cards in the same stream\'s asset index',
|
|
JSON.stringify(index.plans.trains) === JSON.stringify(index.assets[0]) && head.assets.count === 1 && head.plans === undefined && head.plan.sha256 === bundle.plan.sha256);
|
|
}
|
|
|
|
|
|
test('snapshot plans are public files and authenticated dev generation assets', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-public-plans-'));
|
|
try {
|
|
const body = Buffer.from('snapshot'), name = 'plans/card.plan';
|
|
mkdirSync(join(dir, 'plans'));
|
|
writeFileSync(join(dir, name), body);
|
|
const files = await publicFileCards(dir);
|
|
if (!files.some(file => file.name === name && file.bytes === body.length)) throw new Error('entry absent from public cards');
|
|
if (readStaticFile(dir, '/' + name)?.body.toString() !== 'snapshot') throw new Error('entry not served');
|
|
const epoch = 'b'.repeat(32), captured = captureAssets(dir), cache = join(dir, 'cache');
|
|
const id = 'test.widget', plan = Buffer.alloc(36 + id.length);
|
|
plan.write('EXPL'); plan.writeUInt32LE(4, 4); plan.writeBigUInt64LE(1n, 16);
|
|
plan.writeUInt32LE(id.length, 32); plan.write(id, 36);
|
|
const sealed = sealEnvelope({ ...webEnvelope({id,displayName:'Widget'}, plan, assetCards(captured)), dev: {epoch,seq:1} }, `/__dev/generation/${epoch}/1/assets.json`);
|
|
captured.set('assets.json', sealed.index); captured.set('exact.json', sealed.bytes);
|
|
retainDevGeneration(cache, epoch, 1, captured);
|
|
const envelope = JSON.parse(readDevGeneration(cache, `/__dev/generation/${epoch}/1/exact.json`).body);
|
|
const index = readDevGeneration(cache, envelope.assets.url).body, listed = readAssetIndex(envelope.assets, index), { plans } = JSON.parse(index);
|
|
if (plans.card.name !== name || plans.card.sha256 !== sha256(body) || plans.card.url !== listed[0].url) throw new Error('published index omitted the captured entry');
|
|
if (readDevGeneration(cache, `/__dev/generation/${epoch}/1/${name}`)?.body.toString() !== 'snapshot') throw new Error('entry absent from dev generation');
|
|
} finally { rmSync(dir, {recursive:true,force:true}); }
|
|
});
|
|
|
|
|
|
// The first build may compile the web modules when the machine cache is cold.
|
|
test('a production web root serves every authenticated asset card, including activity schemas and types', async () => {
|
|
const root = resolve(import.meta.dir, '..');
|
|
mkdirSync(join(root, 'target'), {recursive:true});
|
|
const dir = mkdtempSync(join(root, 'target/web-activity-root-'));
|
|
try {
|
|
const id = 'test.activity-root', app = {id, displayName:'Activity root'};
|
|
writeFileSync(join(dir, 'app.json'), JSON.stringify({name:app.displayName, app:{id, name:app.displayName}}));
|
|
writeFileSync(join(dir, 'app.contract'), 'component App\n view\n text "Activity root"\n');
|
|
const bake = join(dir, 'bake'), web = join(dir, 'web'), origin = new DirectoryOrigin(join(dir, 'origin'));
|
|
const build = (out, args = []) => {
|
|
const run = spawnSync(process.execPath, ['host/web-js/build.mjs', 'activity-root', '--out', out, '--render', 'none', ...args],
|
|
{cwd:root, env:{...process.env, EXACT_APP_DIR:dir}, encoding:'utf8'});
|
|
if (run.status !== 0) throw new Error(run.stdout + run.stderr);
|
|
};
|
|
build(bake);
|
|
for (const [name, body] of [['plans/card.plan', 'snapshot'], ['activities/card.schema.json', '{"type":"object"}'], ['activities/card.d.ts', 'export type State = {};']]) {
|
|
mkdirSync(dirname(join(bake, name)), {recursive:true});
|
|
writeFileSync(join(bake, name), body);
|
|
}
|
|
const sealed = sealEnvelope(webEnvelope(app, readFileSync(join(bake, 'app.plan')), assetCards(captureAssets(bake))));
|
|
writeFileSync(join(bake, 'assets.json'), sealed.index); writeFileSync(join(bake, 'exact.json'), sealed.bytes);
|
|
build(web, ['--production', '--plan', join(bake, 'app.plan')]);
|
|
const published = await publishRoot({origin, row:{}, web});
|
|
const envelope = JSON.parse(readStaticFile(origin.dir, `/${webReleasePath(published.root)}/exact.json`)?.body ?? 'null');
|
|
const listed = readAssetIndex(envelope.assets, readStaticFile(origin.dir, envelope.assets.url)?.body);
|
|
if (!listed.some(card => card.name === 'activities/card.schema.json')) throw new Error('activity schema absent from published index');
|
|
for (const card of [envelope.plan, ...listed.map(c => ({ ...c, url: new URL(c.url, 'http://o' + envelope.assets.url).pathname }))]) {
|
|
const body = readStaticFile(origin.dir, card.url)?.body;
|
|
if (!body || body.length !== card.bytes || sha256(body) !== card.sha256) throw new Error(`published card is not served: ${card.url}`);
|
|
}
|
|
rmSync(join(web, 'activities/card.schema.json'));
|
|
if (!await rejects(() => publishRoot({origin, row:{}, web}), error => error.message.includes('web envelope does not bind activities/card.schema.json'))) throw new Error('missing asset was published');
|
|
} finally { rmSync(dir, {recursive:true,force:true}); }
|
|
}, 300000);
|
|
|
|
// A TypeScript app with a Live Activity: the Apple bake's generation lists the module pair, which the web bundle
|
|
// carries as its own module, so the macOS stream's bundle adds the pair the bake made beside its receipt.
|
|
test('a native stream of a TypeScript app with a Live Activity publishes the module pair its bake made', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-hold-bundles-'));
|
|
try {
|
|
const card = (name, body) => ({ name, bytes: Buffer.from(body), sha256: sha256(Buffer.from(body)) });
|
|
const plan = card('app.plan', 'plan'), activity = card('plans/departure.plan', 'activity');
|
|
const pair = [card('typescript/app.hbc', 'bytecode'), card('typescript/app.module.json', '{}')];
|
|
const graph = (cards) => ({ artifacts: cards.map(({ name, bytes, sha256 }) => ({ name, sha256, bytes: bytes.length, kind: 'bundle', requires: {} })) });
|
|
const builds = {
|
|
web: { compat: { target: 'wasm32-unknown-unknown', inputs: { platform: 'web' } }, graph: graph([plan, activity]) },
|
|
macos: { compat: { target: 'aarch64-apple-darwin', inputs: { platform: 'macos' } }, graph: graph([plan, activity, ...pair]) },
|
|
};
|
|
const entries = join(dir, 'macos-aarch64-apple-darwin.entries');
|
|
mkdirSync(join(entries, 'typescript'), { recursive: true });
|
|
for (const { name, bytes } of pair) writeFileSync(join(entries, name), bytes);
|
|
const fresh = () => ({ plan: { sha256: plan.sha256, bytes: plan.bytes }, assets: [activity] });
|
|
const bundle = fresh();
|
|
holdBundles(bundle, builds, dir);
|
|
expect(bundle.assets.map((a) => a.name)).toEqual(['plans/departure.plan']);
|
|
expect(Object.keys(bundle.platforms)).toEqual(['macos']);
|
|
expect(bundle.platforms.macos.assets.map((a) => [a.name, a.bytes.toString()]))
|
|
.toEqual([['plans/departure.plan', 'activity'], ['typescript/app.hbc', 'bytecode'], ['typescript/app.module.json', '{}']]);
|
|
// The pair is held to the graph's digests, and any other difference still refuses.
|
|
writeFileSync(join(entries, 'typescript/app.hbc'), 'edited');
|
|
expect(() => holdBundles(fresh(), builds, dir)).toThrow('typescript/app.hbc');
|
|
writeFileSync(join(entries, 'typescript/app.hbc'), 'bytecode');
|
|
builds.macos.graph.artifacts.push({ name: 'plans/other.plan', sha256: sha256(Buffer.from('x')), bytes: 1, kind: 'bundle', requires: {} });
|
|
expect(() => holdBundles(fresh(), builds, dir)).toThrow('macos bake graph differs from the packaged bundle');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// A game's baked texture: the web root carries its three files, and each Apple stream the one family its
|
|
// bake's roster holds (scripts/texture-families.mjs).
|
|
test('a native stream publishes only the texture family its platform reads', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'exact-hold-textures-'));
|
|
try {
|
|
const card = (name) => ({ name, bytes: Buffer.from(name), sha256: sha256(Buffer.from(name)) });
|
|
const plan = card('app.plan'), model = card('assets/fox.model');
|
|
const [rgba, bc, astc] = ['assets/fox/0.tex', 'assets/fox/0.bc.tex', 'assets/fox/0.astc.tex'].map(card);
|
|
const graph = (cards) => ({ artifacts: cards.map(({ name, bytes, sha256 }) => ({ name, sha256, bytes: bytes.length, kind: 'bundle', requires: {} })) });
|
|
const builds = {
|
|
web: { compat: { inputs: { platform: 'web' } }, graph: graph([plan, model, rgba, bc, astc]) },
|
|
macos: { compat: { inputs: { platform: 'macos', textureFamily: 'bc' } }, graph: graph([plan, model, bc]) },
|
|
ios: { compat: { inputs: { platform: 'ios', textureFamily: 'astc' } }, graph: graph([plan, model, astc]) },
|
|
};
|
|
const bundle = { plan: { sha256: plan.sha256, bytes: plan.bytes }, assets: [model, rgba, bc, astc] };
|
|
holdBundles(bundle, builds, dir);
|
|
expect(bundle.assets.length).toBe(4);
|
|
expect(Object.keys(bundle.platforms)).toEqual(['macos', 'ios']);
|
|
expect(bundle.platforms.macos.assets.map((a) => a.name)).toEqual(['assets/fox.model', 'assets/fox/0.bc.tex']);
|
|
expect(bundle.platforms.ios.assets.map((a) => a.name)).toEqual(['assets/fox.model', 'assets/fox/0.astc.tex']);
|
|
// A stream of an iOS cohort baked with ASTC alone is sent ASTC; one baked before thinning, every file.
|
|
expect(streamBundle(bundle, 'ios', { textureFamily: 'astc' }).assets.map((a) => a.name)).toEqual(['assets/fox.model', 'assets/fox/0.astc.tex']);
|
|
expect(streamBundle(bundle, 'ios', { platform: 'ios' }).assets.map((a) => a.name).sort()).toEqual(['assets/fox.model', 'assets/fox/0.astc.tex', 'assets/fox/0.bc.tex', 'assets/fox/0.tex']);
|
|
// A macOS bake that kept another family differs from the bundle its stream publishes.
|
|
builds.macos.graph = graph([plan, model, bc, astc]);
|
|
expect(() => holdBundles({ plan: bundle.plan, assets: [model, rgba, bc, astc] }, builds, dir)).toThrow('macos bake graph differs from the packaged bundle');
|
|
// A bake that thinned nothing names no family, and its stream publishes every file.
|
|
builds.macos = { compat: { inputs: { platform: 'macos' } }, graph: graph([plan, model, rgba, bc, astc]) };
|
|
const whole = { plan: bundle.plan, assets: [model, rgba, bc, astc] };
|
|
holdBundles(whole, builds, dir);
|
|
expect(Object.keys(whole.platforms)).toEqual(['ios']);
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('adding editing and removing only a sunset publishes a new signed sequence', async () => {
|
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
const app = { id: 'com.exact.sunset', displayName: 'Sunset', manifest: { deploy: { signing: { key: 'test', keys: { test: publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('base64') } } } } };
|
|
const signer = { keyId: 'test', sign: head => ({ keyId: 'test', ed25519: cryptoSign(null, canonicalBytes(head), privateKey).toString('base64') }) };
|
|
const stream = { channel: 'prod', compatibilityId: 'a'.repeat(32) };
|
|
const bytes = Buffer.from('same plan');
|
|
const bundle = { plan: { bytes, sha256: sha256(bytes), formatVersion: 4, kernelSchema: '0'.repeat(16) }, assets: [] };
|
|
const initial = streamHead({ app, bundle, stream, seq: 7, release: 'first' });
|
|
initial.signature = signer.sign(initial);
|
|
const path = `.exact/prod/${stream.compatibilityId}/exact.json`;
|
|
const files = new Map([[path, Buffer.from(JSON.stringify(initial))]]);
|
|
let writes = 0;
|
|
const origin = {
|
|
describe: () => 'memory', get: async name => files.get(name) ?? null,
|
|
head: async () => { const bytes = files.get(path); return { bytes, sha256: sha256(bytes), json: JSON.parse(bytes) }; },
|
|
list: async prefix => [...files.keys()].filter(name => name.startsWith(`${prefix}/`)).map(name => name.slice(prefix.length + 1)).filter(name => !name.includes('/')),
|
|
withLock: async (_, fn) => fn(),
|
|
put: async (name, bytes) => { files.set(name, bytes); writes++; return 'written'; },
|
|
putHead: async (_, bytes, { previousDigest }) => { expect(sha256(files.get(path))).toBe(previousDigest); files.set(path, bytes); writes++; },
|
|
};
|
|
const row = { kind: 'stream', platform: 'linux', ...stream, action: 'bundle', changes: [] };
|
|
const compat = { id: stream.compatibilityId, inputs: {} };
|
|
let seq = 7;
|
|
for (const sunset of [{ message: 'Retiring' }, { message: 'Install the next binary', store: 'https://example.test' }, null]) {
|
|
app.manifest.deploy.sunset = { [`prod/${stream.compatibilityId}`]: sunset };
|
|
const published = await publishStream({ origin, row, bundle, compat, app, signer, release: `change-${seq}`, snapshot: { commit: '0'.repeat(40), dirty: false }, opts: {}, log() {} });
|
|
expect(published.seq).toBe(++seq);
|
|
const head = await origin.head();
|
|
expect(head.json.sunset ?? null).toEqual(sunset);
|
|
expect(inspectHead(head, app, stream).authenticated).toBe(true);
|
|
const before = writes;
|
|
const again = await publishStream({ origin, row, bundle, compat, app, signer, release: `same-${seq}`, snapshot: { commit: '0'.repeat(40), dirty: false }, opts: {}, log() {} });
|
|
expect(again.action).toBe('current');
|
|
expect(writes).toBe(before);
|
|
}
|
|
});
|
|
|
|
// The captured deploy with producer boundaries doubled: an existing iOS stream must reach classification on Linux.
|
|
test.each(['new', 'invalid', 'authenticated'])('deploy skips a native bake only for an authenticated cohort (%s)', async state => {
|
|
const root = mkdtempSync(resolve(import.meta.dir, '../target/deploy-bundle-'));
|
|
const sourceRoot = resolve(root, 'exact-source-capture-fixture/source'), exactRoot = resolve(sourceRoot, 'exact');
|
|
const app = { id: 'com.exact.bundle', name: 'bundle', displayName: 'Bundle', dir: exactRoot, workspace: exactRoot, target: root,
|
|
manifest: { host: { ios: {} }, deploy: { signing: { key: 'test', keys: { test: 'public' } } } } };
|
|
const bundle = { plan: { bytes: Buffer.from('plan'), sha256: sha256(Buffer.from('plan')), formatVersion: 4, kernelSchema: '0'.repeat(16) }, assets: [] };
|
|
const build = fixtureBuild('a'.repeat(32), bundle);
|
|
build.trust = 'production';
|
|
build.compat.inputs = { platform: 'ios', store: { L: 'A' }, keys: { test: 'public' }, executors: [] };
|
|
const cohort = cohortReceipt(build), stream = { channel: 'prod', compatibilityId: build.compat.id };
|
|
const calls = [], output = [];
|
|
const bindings = {
|
|
ROOT: exactRoot, canonicalPath: x => x, basename: path => path.split('/').at(-1), dirname, inside: (parent, child) => child.startsWith(parent + '/'),
|
|
process: { env: {}, stderr: { write() {} } }, console: { log: text => output.push(text) },
|
|
resolveApp: () => app, requireReleasePins() {}, channelOf: () => 'prod', validateChannel: x => x,
|
|
channelOrigin: () => root, openOrigin: () => ({ writable: true, describe: () => root }), nativePlatforms: () => ['ios'],
|
|
loadSigner: () => ({ keyId: 'test' }), namesNative: () => true,
|
|
bake: () => root, bakeJs: () => root, readBundle: () => bundle, readBuilds: () => [{ ...build, compat: { ...build.compat, inputs: { ...build.compat.inputs, platform: 'web' } } }],
|
|
buildFor: () => { calls.push('native bake'); return build; },
|
|
holdBundles() {}, rustPackage: () => false, resolve, sealedSourceEnv: (_, env) => env,
|
|
streams: async () => state === 'authenticated' ? [{ stream, head: {}, cohort, platform: 'ios' }] : state === 'new' ? [] : [{ stream, platform: 'ios', problem: 'unauthenticated' }],
|
|
streamCandidates: async () => [{ row: { kind: 'stream', platform: 'ios', ...stream }, compat: build.compat, build, bundle }],
|
|
classify: async () => ({ snapshot: {}, rows: [{ kind: 'stream', platform: 'ios', ...stream, receipt: cohort, action: 'bundle' }] }),
|
|
streamBundle, publishStream: async () => { calls.push('publish'); return { kind: 'stream', action: 'published', seq: 8, head: { sha256: 'b'.repeat(64) } }; },
|
|
refuse: message => { throw Error(message); },
|
|
};
|
|
const source = readFileSync(DEPLOY, 'utf8');
|
|
const fn = source.slice(source.indexOf('async function deployCaptured('), source.indexOf('/** An explicitly supplied'));
|
|
const deploy = new Function(...Object.keys(bindings), fn + ';return deployCaptured;')(...Object.values(bindings));
|
|
try {
|
|
expect(await deploy({ _: ['bundle'], platform: ['ios'], keys: root, yes: true, json: true }, {
|
|
version: 1, run: resolve(root, 'run'), sourceRoot, exactRoot, release: 'update', app,
|
|
snapshot: { id: 'snapshot', sources: [{ repo: resolve(root, 'live'), roles: ['exact'], commit: 'a'.repeat(40) }] }, receipts: [],
|
|
})).toBe(0);
|
|
expect(calls).toEqual(state === 'authenticated' ? ['publish'] : ['native bake', 'publish']);
|
|
expect(JSON.parse(output.at(-1)).published[0].seq).toBe(8);
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|