fix: tighten installer migration authoring guards

This commit is contained in:
Tom Boucher
2026-05-11 14:36:14 -04:00
parent b20b5fba06
commit c4fe891391
6 changed files with 137 additions and 14 deletions

View File

@@ -144,6 +144,39 @@ test('rejects destructive migration actions without ownership evidence', (t) =>
);
});
test('rejects migration actions with absolute or traversal relPaths', (t) => {
const configDir = createTempDir('gsd-migration-authoring-relpath-');
t.after(() => cleanup(configDir));
fs.writeFileSync(
path.join(configDir, 'gsd-file-manifest.json'),
JSON.stringify({ version: '1.50.0', timestamp: '2026-05-11T00:00:00.000Z', mode: 'full', files: {} }),
'utf8'
);
for (const relPath of ['/tmp/outside.js', 'hooks/../outside.js', 'hooks/..', '.']) {
assert.throws(
() => planInstallerMigrations({
configDir,
migrations: [
completeMigrationRecord({
plan: () => [
{
type: 'remove-managed',
relPath,
reason: 'bad path',
ownershipEvidence: 'test fixture manifest-managed hook',
},
],
}),
],
scope: 'global',
}),
/relPath must stay inside configDir/
);
}
});
test('rejects runtime config rewrites without a runtime contract citation', (t) => {
const configDir = createTempDir('gsd-migration-authoring-runtime-');
t.after(() => cleanup(configDir));

View File

@@ -1128,6 +1128,41 @@ test('runs discovered installer migrations against manifest-managed legacy orpha
}
});
test('backs up modified legacy orphan files before removing them', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'hooks/statusline.js', 'user modified legacy hook\n');
writeManifest(configDir, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
const plan = planInstallerMigrations({
configDir,
migrations: discoverInstallerMigrations({
migrationsDir: path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'installer-migrations'),
}),
scope: 'global',
now: () => '2026-05-11T00:00:05.000Z',
});
const action = plan.actions.find((item) => item.relPath === 'hooks/statusline.js');
assert.equal(action.type, 'backup-and-remove');
const result = runInstallerMigrations({
configDir,
scope: 'global',
now: () => '2026-05-11T00:00:05.000Z',
});
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
const backupRelPath = journal.actions.find((item) => item.relPath === 'hooks/statusline.js').backupRelPath;
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user modified legacy hook\n');
} finally {
cleanup(configDir);
}
});
test('runs a Codex legacy hooks.json cleanup migration without removing user hooks', () => {
const configDir = createTempInstall();
try {
@@ -1164,6 +1199,40 @@ test('runs a Codex legacy hooks.json cleanup migration without removing user hoo
}
});
test('preserves unrelated empty hooks.json structure while pruning legacy Codex hooks', () => {
const configDir = createTempInstall();
try {
writeFile(
configDir,
'hooks.json',
JSON.stringify({
SessionStart: [
legacyCodexHook(configDir),
{ hooks: [] },
{ metadata: null },
],
}, null, 2)
);
writeManifest(configDir, {});
runInstallerMigrations({
configDir,
runtime: 'codex',
scope: 'global',
now: () => '2026-05-11T00:00:06.000Z',
});
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
assert.deepEqual(hooksJson.SessionStart, [
{ hooks: [] },
{ metadata: null },
]);
} finally {
cleanup(configDir);
}
});
test('skips runtime-specific migration records for other runtimes', () => {
const configDir = createTempInstall();
try {