mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-26 22:51:47 +02:00
* fix(settings): preserve symlinked settings targets
Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.
* fix(redact): bind masking to original detected spans
Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.
* fix(evals): exclude operator credentials from prefix admission
Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.
* fix(artifacts): retain custom allowlist rules on reinitialization
Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.
* test(cso): verify exact masked reads and unmaskable payload refusal
* fix(cso): preserve exact filesystem identities through lease recovery
Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.
* fix(redact): bind pre-push scans to destination and preserve seam context
Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.
* test(ci): gate native agent ownership and settings links on macOS
* fix(browse): bind agent lifetimes and cleanup to owned generations
Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.
* test(ci): include concurrent shutdown controls in the native macOS gate
* v1.88.1.0 fix: harden credential boundaries and owned state
* fix(redact): preserve target provenance and scan boundary semantics
* test(artifacts): read managed rules from atomic allowlist assembly
* fix: preserve native exit observations and fixture prerequisites
* fix: preserve UTF-16 offsets through redaction normalization
269 lines
13 KiB
TypeScript
269 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { canRevokeReads } from './helpers/fs-caps';
|
|
|
|
const hook = path.resolve(import.meta.dir, '../bin/gstack-settings-hook');
|
|
let root: string;
|
|
let target: string;
|
|
let settings: string;
|
|
|
|
beforeEach(() => {
|
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-link-')));
|
|
fs.mkdirSync(path.join(root, 'config'));
|
|
fs.mkdirSync(path.join(root, 'dotfiles'));
|
|
target = path.join(root, 'dotfiles/settings.json');
|
|
settings = path.join(root, 'config/settings.json');
|
|
fs.writeFileSync(target, '{"theme":"dark","hooks":{}}\n', { mode: 0o600 });
|
|
fs.symlinkSync('../dotfiles/settings.json', settings);
|
|
expect(fs.realpathSync(settings)).toBe(target);
|
|
expect(target.startsWith(root + path.sep)).toBe(true);
|
|
});
|
|
|
|
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
|
|
function env(file = settings) {
|
|
return { ...process.env, HOME: root, GSTACK_HOME: path.join(root, 'state'), GSTACK_SETTINGS_FILE: file };
|
|
}
|
|
|
|
function addArgs(source = 'link-test') {
|
|
return ['add-event', '--event', 'PostToolUse', '--command', '/fixture/hook', '--source', source];
|
|
}
|
|
|
|
function run(args: string[], file = settings) {
|
|
return spawnSync('bash', [hook, ...args], { env: env(file), encoding: 'utf8', timeout: 10_000 });
|
|
}
|
|
|
|
function runWithRealpathFailure(args: string[], code: string, afterTemp = false) {
|
|
const fakeBin = path.join(root, 'bin');
|
|
const preload = path.join(root, 'realpath-permission.ts');
|
|
fs.mkdirSync(fakeBin);
|
|
fs.writeFileSync(preload, `const fs = require('fs');
|
|
const original = fs.realpathSync;
|
|
fs.realpathSync = (file, ...args) => {
|
|
if (String(file) === process.env.GSTACK_SETTINGS_INPUT && (!${afterTemp} || (process.env.GSTACK_TMP_PATH && fs.existsSync(process.env.GSTACK_TMP_PATH)))) {
|
|
throw Object.assign(new Error('synthetic realpath permission failure'), { code: ${JSON.stringify(code)} });
|
|
}
|
|
return original(file, ...args);
|
|
};
|
|
`);
|
|
fs.writeFileSync(path.join(fakeBin, 'bun'), '#!/bin/sh\nexec "$ACTUAL_BUN" --preload "$FS_PROBE" "$@"\n', { mode: 0o755 });
|
|
return spawnSync('bash', [hook, ...args], {
|
|
env: { ...env(), PATH: fakeBin + path.delimiter + process.env.PATH, ACTUAL_BUN: process.execPath, FS_PROBE: preload },
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
}
|
|
|
|
describe('settings hook preserves the resolved settings target', () => {
|
|
test('add, ensure and remove preserve the link, real target and private mode', () => {
|
|
const add = run(addArgs());
|
|
expect(add.status, add.stderr).toBe(0);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.readlinkSync(settings)).toBe('../dotfiles/settings.json');
|
|
expect(JSON.parse(fs.readFileSync(target, 'utf8')).hooks.PostToolUse[0]._gstack_source).toBe('link-test');
|
|
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
|
|
const before = fs.readFileSync(target, 'utf8');
|
|
const ensure = run(['ensure-event', ...addArgs().slice(1)]);
|
|
expect(ensure.status, ensure.stderr).toBe(0);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe(before);
|
|
const remove = run(['remove-source', '--source', 'link-test']);
|
|
expect(remove.status, remove.stderr).toBe(0);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(JSON.parse(fs.readFileSync(target, 'utf8')).theme).toBe('dark');
|
|
expect(fs.readFileSync(target, 'utf8')).not.toContain('link-test');
|
|
});
|
|
|
|
test('backup and rollback are siblings of the real target, not the link', () => {
|
|
const original = fs.readFileSync(target, 'utf8');
|
|
const add = run(addArgs());
|
|
expect(add.status, add.stderr).toBe(0);
|
|
expect(fs.existsSync(target + '.bak-latest')).toBe(true);
|
|
expect(fs.existsSync(settings + '.bak-latest')).toBe(false);
|
|
const rollback = run(['rollback']);
|
|
expect(rollback.status, rollback.stderr).toBe(0);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
expect(fs.statSync(target).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
test('different aliases contend for the same target lock', () => {
|
|
fs.mkdirSync(target + '.lock');
|
|
fs.writeFileSync(target + '.lock/owner', 'fixture-owner');
|
|
const result = spawnSync('bash', [hook, ...addArgs()], {
|
|
env: { ...env(), GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '100' },
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(result.status).toBe(5);
|
|
expect(result.stderr).toContain('could not acquire lock');
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.readFileSync(target, 'utf8')).not.toContain('link-test');
|
|
expect(fs.existsSync(settings + '.lock')).toBe(false);
|
|
});
|
|
|
|
test('dangling and cyclic links are rejected without replacing them', () => {
|
|
fs.unlinkSync(target);
|
|
const dangling = run(addArgs());
|
|
expect(dangling.status).not.toBe(0);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.existsSync(target)).toBe(false);
|
|
fs.symlinkSync('../config/settings.json', target);
|
|
const cyclic = run(addArgs());
|
|
expect(cyclic.status).not.toBe(0);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.lstatSync(target).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
test('malformed linked settings stay unchanged', () => {
|
|
fs.writeFileSync(target, '{not json');
|
|
const result = run(addArgs());
|
|
expect(result.status).toBe(3);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe('{not json');
|
|
});
|
|
|
|
test('a missing ordinary settings file can still be created', () => {
|
|
const fresh = path.join(root, 'config/fresh.json');
|
|
const result = run(addArgs(), fresh);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(fs.lstatSync(fresh).isFile()).toBe(true);
|
|
expect(JSON.parse(fs.readFileSync(fresh, 'utf8')).hooks.PostToolUse).toHaveLength(1);
|
|
expect(fs.statSync(fresh).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
test('read-only commands and absent-parent no-ops do not create files', () => {
|
|
expect(run(addArgs()).status).toBe(0);
|
|
const original = fs.readFileSync(target, 'utf8');
|
|
const siblings = fs.readdirSync(path.dirname(target));
|
|
expect(run(['list-sources']).stdout).toContain('link-test');
|
|
expect(run(['list-items', '--event', 'PostToolUse']).stdout).toContain('/fixture/hook');
|
|
expect(run(['diff-event', ...addArgs().slice(1)]).status).toBe(0);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
expect(fs.readdirSync(path.dirname(target))).toEqual(siblings);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
const missing = path.join(root, 'absent/config/settings.json');
|
|
expect(run(['list-sources'], missing).status).toBe(0);
|
|
expect(run(['remove-source', '--source', 'link-test'], missing).status).toBe(0);
|
|
expect(fs.existsSync(path.join(root, 'absent'))).toBe(false);
|
|
});
|
|
|
|
test('legacy add, remove and prune-stale use the real target', () => {
|
|
const command = path.join(root, 'missing/bin/gstack-session-update');
|
|
expect(run(['add', command]).status).toBe(0);
|
|
expect(fs.readFileSync(target, 'utf8')).toContain(command);
|
|
expect(run(['remove', command]).status).toBe(0);
|
|
expect(fs.readFileSync(target, 'utf8')).not.toContain(command);
|
|
expect(run(['add', command]).status).toBe(0);
|
|
expect(run(['prune-stale']).status).toBe(0);
|
|
expect(fs.readFileSync(target, 'utf8')).not.toContain(command);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(JSON.parse(fs.readFileSync(target, 'utf8')).theme).toBe('dark');
|
|
});
|
|
|
|
test.skipIf(!canRevokeReads())('an unreadable linked target stays untouched', () => {
|
|
const original = fs.readFileSync(target, 'utf8');
|
|
fs.chmodSync(target, 0o000);
|
|
try {
|
|
const result = run(addArgs());
|
|
expect(result.status, result.stderr).toBe(3);
|
|
expect(result.stderr).toContain('cannot read');
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.readdirSync(path.dirname(target))).toEqual(['settings.json']);
|
|
} finally {
|
|
fs.chmodSync(target, 0o600);
|
|
}
|
|
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
});
|
|
|
|
for (const code of ['EACCES', 'EPERM']) {
|
|
test(`a ${code} realpath failure keeps the unreadable-settings exit contract`, () => {
|
|
const original = fs.readFileSync(target, 'utf8');
|
|
const result = runWithRealpathFailure(addArgs(), code);
|
|
expect(result.status, result.stderr).toBe(3);
|
|
expect(result.stderr).toContain('cannot read');
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.readdirSync(path.dirname(target))).toEqual(['settings.json']);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
});
|
|
}
|
|
|
|
for (const action of ['add-event', 'rollback']) {
|
|
test(`a late permission failure during ${action} removes its temporary settings copy`, () => {
|
|
if (action === 'rollback') expect(run(addArgs()).status).toBe(0);
|
|
const original = fs.readFileSync(target, 'utf8');
|
|
const result = runWithRealpathFailure(action === 'rollback' ? ['rollback'] : addArgs(), 'EACCES', true);
|
|
expect(result.status, result.stderr).toBe(3);
|
|
expect(result.stderr).toContain('cannot read');
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
expect(fs.readdirSync(path.dirname(target)).filter(name => name.startsWith('settings.json.tmp.'))).toEqual([]);
|
|
});
|
|
}
|
|
|
|
test('concurrent writes through file and directory aliases both survive', async () => {
|
|
const directoryAlias = path.join(root, 'linked-directory');
|
|
fs.symlinkSync('dotfiles', directoryAlias);
|
|
const directorySettings = path.join(directoryAlias, 'settings.json');
|
|
expect(fs.realpathSync(directorySettings)).toBe(target);
|
|
const first = Bun.spawn(['bash', hook, ...addArgs()], { env: env(), stdout: 'pipe', stderr: 'pipe' });
|
|
const second = Bun.spawn(['bash', hook, 'add-event', '--event', 'Stop', '--command', '/fixture/stop', '--source', 'second-source'], {
|
|
env: env(directorySettings), stdout: 'pipe', stderr: 'pipe',
|
|
});
|
|
try {
|
|
const statuses = await Promise.all([first.exited, second.exited]);
|
|
expect(statuses).toEqual([0, 0]);
|
|
const stored = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
expect(stored.hooks.PostToolUse[0]._gstack_source).toBe('link-test');
|
|
expect(stored.hooks.Stop[0]._gstack_source).toBe('second-source');
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
expect(fs.lstatSync(directoryAlias).isSymbolicLink()).toBe(true);
|
|
} finally {
|
|
if (first.exitCode === null) first.kill();
|
|
if (second.exitCode === null) second.kill();
|
|
await Promise.all([first.exited, second.exited]);
|
|
}
|
|
});
|
|
|
|
test('a link retargeted while waiting for its lock cannot mutate either target', async () => {
|
|
const other = path.join(root, 'dotfiles/other.json');
|
|
const original = fs.readFileSync(target, 'utf8');
|
|
fs.writeFileSync(other, original);
|
|
fs.mkdirSync(target + '.lock');
|
|
fs.writeFileSync(target + '.lock/owner', 'fixture-owner');
|
|
const bin = path.join(root, 'bin');
|
|
fs.mkdirSync(bin);
|
|
const realMkdir = spawnSync('which', ['mkdir'], { encoding: 'utf8', timeout: 5_000 }).stdout.trim();
|
|
expect(realMkdir).not.toBe('');
|
|
const observed = path.join(root, 'lock-observed');
|
|
fs.writeFileSync(path.join(bin, 'mkdir'), '#!/bin/bash\nif [ "$1" = "$LOCK_EXPECTED" ]; then printf seen > "$LOCK_OBSERVED"; fi\nexec "$REAL_MKDIR" "$@"\n', { mode: 0o755 });
|
|
const child = spawn('bash', [hook, ...addArgs()], {
|
|
env: { ...env(), PATH: `${bin}:${process.env.PATH}`, LOCK_EXPECTED: target + '.lock', LOCK_OBSERVED: observed, REAL_MKDIR: realMkdir },
|
|
stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000,
|
|
});
|
|
let stderr = '';
|
|
child.stdout.resume();
|
|
child.stderr.on('data', data => { stderr += data; });
|
|
const finished = new Promise<number | null>((resolve, reject) => {
|
|
child.once('error', reject);
|
|
child.once('close', resolve);
|
|
});
|
|
try {
|
|
const deadline = Date.now() + 2_000;
|
|
while (!fs.existsSync(observed) && Date.now() < deadline) await Bun.sleep(10);
|
|
expect(fs.existsSync(observed)).toBe(true);
|
|
fs.unlinkSync(settings);
|
|
fs.symlinkSync('../dotfiles/other.json', settings);
|
|
expect(fs.realpathSync(settings)).toBe(other);
|
|
fs.rmSync(target + '.lock', { recursive: true });
|
|
expect(await finished, stderr).not.toBe(0);
|
|
expect(fs.readFileSync(target, 'utf8')).toBe(original);
|
|
expect(fs.readFileSync(other, 'utf8')).toBe(original);
|
|
expect(fs.lstatSync(settings).isSymbolicLink()).toBe(true);
|
|
} finally {
|
|
if (child.exitCode === null) child.kill();
|
|
await finished;
|
|
}
|
|
});
|
|
});
|