325 lines
13 KiB
JavaScript
325 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { stageCleanSkill } from '../../scripts/stage-clean-skill.mjs';
|
|
|
|
const stagerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../scripts/stage-clean-skill.mjs');
|
|
const canonicalNotices = fs.readFileSync(
|
|
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../THIRD_PARTY_NOTICES.md'),
|
|
'utf8',
|
|
);
|
|
|
|
function git(root, args) {
|
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
}
|
|
|
|
function write(root, relative, content, mode = null) {
|
|
const target = path.join(root, relative);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, content);
|
|
if (mode !== null) fs.chmodSync(target, mode);
|
|
return target;
|
|
}
|
|
|
|
function repositoryFixture() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-clean-stage-'));
|
|
write(root, 'THIRD_PARTY_NOTICES.md', canonicalNotices);
|
|
write(root, 'archify/LICENSE', 'MIT License\n');
|
|
write(root, 'archify/THIRD_PARTY_NOTICES.md', canonicalNotices);
|
|
write(root, 'archify/package.json', JSON.stringify({
|
|
name: 'archify-fixture',
|
|
scripts: { test: 'node --test' },
|
|
devDependencies: { ajv: '1.0.0' },
|
|
}));
|
|
write(root, 'archify/package-lock.json', '{}\n');
|
|
write(root, 'archify/skill-release.json', '{}\n');
|
|
write(root, 'archify/scripts/check-update.mjs', 'export {};\n');
|
|
write(root, 'archify/scripts/update-contract.mjs', 'export {};\n');
|
|
write(root, 'archify/renderers/shared/generated-validators.mjs', 'export {};\n');
|
|
write(root, 'archify/test/repository-only.test.mjs', 'throw new Error();\n');
|
|
git(root, ['init']);
|
|
git(root, ['add', 'THIRD_PARTY_NOTICES.md']);
|
|
return root;
|
|
}
|
|
|
|
test('clean staging rejects a packaged notice that diverges from the repository notice', () => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
try {
|
|
git(root, ['add', '.']);
|
|
fs.writeFileSync(
|
|
path.join(root, 'archify', 'THIRD_PARTY_NOTICES.md'),
|
|
canonicalNotices.replace('Simple Icons 16.28.0', 'Simple Icons 16.28.0 modified'),
|
|
);
|
|
|
|
assert.throws(
|
|
() => stageCleanSkill({ repoRoot: root, destination }),
|
|
/must byte-match the repository notice/,
|
|
);
|
|
assert.equal(fs.existsSync(destination), false);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('clean staging rejects byte-identical but incomplete repository and packaged notices', () => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
try {
|
|
const incomplete = canonicalNotices.replace(/## OpenAI mark[\s\S]*?## No additional rights granted/, '## No additional rights granted');
|
|
fs.writeFileSync(path.join(root, 'THIRD_PARTY_NOTICES.md'), incomplete);
|
|
fs.writeFileSync(path.join(root, 'archify', 'THIRD_PARTY_NOTICES.md'), incomplete);
|
|
git(root, ['add', '.']);
|
|
|
|
assert.throws(
|
|
() => stageCleanSkill({ repoRoot: root, destination }),
|
|
/repository THIRD_PARTY_NOTICES\.md is incomplete; missing required disclosure: .*OpenAI/,
|
|
);
|
|
assert.equal(fs.existsSync(destination), false);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('clean staging preserves index modes and strips repository-only package metadata', () => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
try {
|
|
write(root, 'archify/bin/executable.mjs', '#!/usr/bin/env node\n', 0o755);
|
|
write(root, 'archify/runtime/test/required.dat', 'runtime fixture\n');
|
|
git(root, ['add', 'archify']);
|
|
|
|
stageCleanSkill({ repoRoot: root, destination });
|
|
|
|
assert.equal(fs.statSync(path.join(destination, 'bin', 'executable.mjs')).mode & 0o777, 0o755);
|
|
assert.equal(fs.existsSync(path.join(destination, 'test')), false);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(destination, 'runtime', 'test', 'required.dat'), 'utf8'),
|
|
'runtime fixture\n',
|
|
'only the repository-root test tree is excluded',
|
|
);
|
|
assert.equal(fs.existsSync(path.join(destination, 'package-lock.json')), false);
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(destination, 'package.json'), 'utf8'));
|
|
assert.equal(Object.hasOwn(packageJson, 'scripts'), false);
|
|
assert.equal(Object.hasOwn(packageJson, 'devDependencies'), false);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('clean staging rejects a symlink in a tracked file ancestor before copying bytes', (t) => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
try {
|
|
const runtime = path.join(root, 'archify', 'runtime');
|
|
write(root, 'archify/runtime/payload.txt', 'tracked fixture\n');
|
|
git(root, ['add', 'archify']);
|
|
fs.rmSync(runtime, { recursive: true });
|
|
const external = path.join(root, 'outside-runtime');
|
|
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
|
try {
|
|
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`symlinks unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
assert.throws(
|
|
() => stageCleanSkill({ repoRoot: root, destination }),
|
|
/refusing to package path through symlink: archify\/runtime/,
|
|
);
|
|
assert.equal(fs.existsSync(destination), false);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('clean staging rejects tracked symlinks before reading through them', (t) => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
try {
|
|
const external = write(root, 'outside.txt', 'private fixture\n');
|
|
const linked = path.join(root, 'archify', 'linked.txt');
|
|
try {
|
|
fs.symlinkSync(external, linked);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`symlinks unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
git(root, ['add', 'archify']);
|
|
|
|
assert.throws(
|
|
() => stageCleanSkill({ repoRoot: root, destination }),
|
|
/refusing to package tracked symlink: archify\/linked\.txt/,
|
|
);
|
|
assert.equal(fs.existsSync(destination), false);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('clean staging snapshots unstaged tracked bytes before a source ancestor can be swapped', (t) => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
const runtime = path.join(root, 'archify', 'runtime');
|
|
const external = path.join(root, 'outside-runtime');
|
|
const originalMkdirSync = fs.mkdirSync;
|
|
let swapped = false;
|
|
try {
|
|
const payload = write(root, 'archify/runtime/payload.txt', 'indexed fixture\n');
|
|
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
|
git(root, ['add', 'archify']);
|
|
fs.writeFileSync(payload, 'unstaged working-tree fixture\n');
|
|
|
|
const probe = path.join(root, 'symlink-probe');
|
|
try {
|
|
fs.symlinkSync(external, probe, process.platform === 'win32' ? 'junction' : 'dir');
|
|
fs.rmSync(probe, { force: true });
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`symlinks unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
fs.mkdirSync = function swapSourceAfterSnapshot(target, ...args) {
|
|
const result = originalMkdirSync.call(fs, target, ...args);
|
|
if (!swapped && path.resolve(target) === path.resolve(destination)) {
|
|
fs.rmSync(runtime, { recursive: true });
|
|
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
|
swapped = true;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
stageCleanSkill({ repoRoot: root, destination });
|
|
|
|
assert.equal(swapped, true, 'the deterministic ancestor-swap attack must run');
|
|
assert.equal(
|
|
fs.readFileSync(path.join(destination, 'runtime', 'payload.txt'), 'utf8'),
|
|
'unstaged working-tree fixture\n',
|
|
'staging keeps the tracked working-tree snapshot and never follows the replacement ancestor',
|
|
);
|
|
} finally {
|
|
fs.mkdirSync = originalMkdirSync;
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('clean staging rejects a source ancestor swapped during preflight traversal', (t) => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
const runtime = path.join(root, 'archify', 'runtime');
|
|
const external = path.join(root, 'outside-runtime');
|
|
const originalLstatSync = fs.lstatSync;
|
|
let swapped = false;
|
|
try {
|
|
write(root, 'archify/runtime/payload.txt', 'tracked fixture\n');
|
|
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
|
git(root, ['add', 'archify']);
|
|
const canonicalRuntime = path.join(fs.realpathSync(root), 'archify', 'runtime');
|
|
|
|
const probe = path.join(root, 'symlink-probe');
|
|
try {
|
|
fs.symlinkSync(external, probe, process.platform === 'win32' ? 'junction' : 'dir');
|
|
fs.rmSync(probe, { force: true });
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`symlinks unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
fs.lstatSync = function swapSourceBetweenAncestorAndLeaf(target, ...args) {
|
|
const metadata = originalLstatSync.call(fs, target, ...args);
|
|
if (!swapped && path.resolve(target) === canonicalRuntime) {
|
|
// Guard before mutation: recursive removal can re-enter the patched
|
|
// lstatSync implementation on Linux.
|
|
swapped = true;
|
|
fs.rmSync(runtime, { recursive: true });
|
|
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
|
}
|
|
return metadata;
|
|
};
|
|
|
|
assert.throws(
|
|
() => stageCleanSkill({ repoRoot: root, destination }),
|
|
/(?:tracked package path changed before it could be read: archify\/|tracked package input is missing or unreadable: archify\/runtime\/payload\.txt)/,
|
|
);
|
|
assert.equal(swapped, true, 'the deterministic mid-preflight ancestor swap must run');
|
|
assert.equal(fs.existsSync(destination), false);
|
|
} finally {
|
|
fs.lstatSync = originalLstatSync;
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('clean staging reports the Git spawn error when Git cannot start', () => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
try {
|
|
git(root, ['add', 'archify']);
|
|
const result = spawnSync(process.execPath, [
|
|
stagerPath,
|
|
'--root', root,
|
|
'--dest', destination,
|
|
], {
|
|
encoding: 'utf8',
|
|
env: { ...process.env, PATH: '' },
|
|
});
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /unable to enumerate tracked Archify files: .*ENOENT/);
|
|
assert.doesNotMatch(result.stderr, /tracked Archify paths must be valid UTF-8/);
|
|
assert.equal(fs.existsSync(destination), false);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Historical DSH snapshots predate embedded fonts and must remain packageable.
|
|
for (const fontPath of [null, 'archify/assets/template.html', 'archify/examples/standalone.html']) {
|
|
const embedded = fontPath !== null;
|
|
test(`clean staging applies font disclosures to snapshot contents (fontPath=${fontPath})`, () => {
|
|
const root = repositoryFixture();
|
|
const destination = path.join(root, 'staged-skill');
|
|
try {
|
|
const legacy = canonicalNotices.replace(/## JetBrains Mono[\s\S]*?(?=\n## |$)/, '');
|
|
write(root, 'THIRD_PARTY_NOTICES.md', legacy);
|
|
write(root, 'archify/THIRD_PARTY_NOTICES.md', legacy);
|
|
write(root, 'archify/assets/template.html', '<html>legacy viewer</html>');
|
|
if (embedded) write(root, fontPath, '@font-face { src: url(data:font/woff2;base64,fixture); }');
|
|
write(root, 'archify/assets/JetBrainsMono-OFL.txt', 'fixture license');
|
|
git(root, ['add', '.']);
|
|
if (embedded) {
|
|
assert.throws(() => stageCleanSkill({ repoRoot: root, destination }), /missing required disclosure: JetBrains Mono/);
|
|
assert.equal(fs.existsSync(destination), false);
|
|
write(root, 'THIRD_PARTY_NOTICES.md', canonicalNotices);
|
|
write(root, 'archify/THIRD_PARTY_NOTICES.md', canonicalNotices);
|
|
stageCleanSkill({ repoRoot: root, destination });
|
|
assert.equal(fs.readFileSync(path.join(destination, 'assets/JetBrainsMono-OFL.txt'), 'utf8'), 'fixture license');
|
|
fs.rmSync(destination, { recursive: true });
|
|
fs.unlinkSync(path.join(root, 'archify/assets/JetBrainsMono-OFL.txt'));
|
|
git(root, ['add', '.']);
|
|
assert.throws(() => stageCleanSkill({ repoRoot: root, destination }), /requires assets\/JetBrainsMono-OFL.txt/);
|
|
} else {
|
|
stageCleanSkill({ repoRoot: root, destination });
|
|
assert.equal(fs.readFileSync(path.join(destination, 'THIRD_PARTY_NOTICES.md'), 'utf8'), legacy);
|
|
}
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
}
|