mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.33.0 fix(sources): confine sync re-clone to gbrain-owned clones; never delete a user working tree (#1881) (#1960)
* fix(sources): confine sync re-clone to gbrain-owned clones; never delete a user working tree (#1881) recloneIfMissing deleted local_path whenever a source had a remote_url and a non-healthy on-disk state, with no check that gbrain actually created the clone. A source whose local_path was a user's live working tree (remote_url set, no gbrain-created clone) could have its directory removed and re-cloned over. - isOwnedClone(): ownership, not path-containment. True only for a config .managed_clone marker (written by addSource --url) or exact normalized-path equality with defaultCloneDir(id) (back-compat for pre-marker default clones). - recloneIfMissing: ownership guard aborts before ANY filesystem op; EXDEV-safe sibling-temp clone + atomic swap (old aside -> new in -> drop old) with best-effort restore + a message naming where the original is preserved; symlink-leaf reject before the destructive rename. - sync.ts validate_repo_state guards reclone on isOwnedClone (no per-sync warn). - sources restore degrades to a warning for an unowned source instead of the misleading "missing clone, try sync" hint. Tests: #1881 regression (tree survives), isOwnedClone matrix, symlink reject, EXDEV swap residue-free, --clone-dir owned-via-marker, restore CV3, unownedHint healthy/degraded, sync-level refusal. * chore: bump version and changelog (v0.42.33.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document sources-ops reclone-ownership invariant for v0.42.33.0 (#1881) Add the missing src/core/sources-ops.ts entry to KEY_FILES.md capturing the must-never-violate reclone-ownership guarantee: gbrain only deletes/re-clones a clone it created (isOwnedClone), never a user working tree. Covers managed_clone marker, defaultCloneDir back-compat, EXDEV-safe swap, TOCTOU + symlink-leaf guards, unmanaged_path SourceOpError, and the read-only sources restore path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a06af5a57
commit
b31de6613e
@@ -23,9 +23,13 @@ import {
|
||||
getSourceStatus,
|
||||
recloneIfMissing,
|
||||
isPathContained,
|
||||
isOwnedClone,
|
||||
unownedHint,
|
||||
defaultCloneDir,
|
||||
SourceOpError,
|
||||
} from '../src/core/sources-ops.ts';
|
||||
import { readdirSync } from 'fs';
|
||||
import { runSources } from '../src/commands/sources.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
@@ -508,6 +512,273 @@ describe('recloneIfMissing — T4 restore + autopurge recovery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1881 — ownership guard: recloneIfMissing must NEVER delete a user working
|
||||
// tree. Ownership (config.managed_clone OR default-location equality), not
|
||||
// path-containment.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isOwnedClone — ownership predicate', () => {
|
||||
test('marker config.managed_clone:true → owned (even at an external path)', async () => {
|
||||
await withEnv2(async () => {
|
||||
expect(
|
||||
isOwnedClone({
|
||||
id: 'x',
|
||||
local_path: '/some/external/path',
|
||||
config: { remote_url: 'https://github.com/example/repo', managed_clone: true },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('default-location clone, no marker → owned (back-compat equality)', async () => {
|
||||
await withEnv2(async () => {
|
||||
expect(
|
||||
isOwnedClone({
|
||||
id: 'legacy',
|
||||
local_path: defaultCloneDir('legacy'),
|
||||
config: { remote_url: 'https://github.com/example/repo' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('external path, no marker → NOT owned (the #1881 federated shape)', async () => {
|
||||
await withEnv2(async () => {
|
||||
expect(
|
||||
isOwnedClone({
|
||||
id: 'gstack-code-app-abc',
|
||||
local_path: '/Users/dev/tt-flutter-app',
|
||||
config: { remote_url: 'https://github.com/example/repo', federated: true },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('null local_path → NOT owned', async () => {
|
||||
await withEnv2(async () => {
|
||||
expect(isOwnedClone({ id: 'x', local_path: null, config: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('config as JSON string (DB shape) is parsed', async () => {
|
||||
await withEnv2(async () => {
|
||||
expect(
|
||||
isOwnedClone({
|
||||
id: 'x',
|
||||
local_path: '/external',
|
||||
config: JSON.stringify({ managed_clone: true }),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unownedHint — healthy vs degraded guidance', () => {
|
||||
test('healthy: read-only guidance, no "missing clone" framing', () => {
|
||||
const msg = unownedHint({ id: 'x', local_path: '/Users/dev/repo' }, 'healthy');
|
||||
expect(msg).toMatch(/read-only/);
|
||||
expect(msg).toMatch(/drop config\.remote_url/);
|
||||
expect(msg).not.toMatch(/not a usable git repo/);
|
||||
});
|
||||
|
||||
test('degraded: names the state and does not suggest dropping remote_url alone recovers it', () => {
|
||||
const msg = unownedHint({ id: 'x', local_path: '/Users/dev/repo' }, 'no-git');
|
||||
expect(msg).toMatch(/not a usable git repo/);
|
||||
expect(msg).toMatch(/no-git/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recloneIfMissing — refuses to delete an unowned working tree (#1881)', () => {
|
||||
test('external local_path + remote_url, no marker → throws unmanaged_path, tree survives', async () => {
|
||||
await withEnv2(async () => {
|
||||
// Simulate the gstack orchestrator's federated row: remote_url set, but
|
||||
// local_path points at a live user working tree (no .git → no-git state),
|
||||
// and NO managed_clone marker.
|
||||
const userTree = join(FAKE_GIT_DIR, 'user-working-tree');
|
||||
rmSync(userTree, { recursive: true, force: true });
|
||||
mkdirSync(userTree, { recursive: true });
|
||||
const sentinel = join(userTree, 'KEEP_ME.txt');
|
||||
writeFileSync(sentinel, 'two unpushed commits live here');
|
||||
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config)
|
||||
VALUES ('gstack-code-app-abc', 'flutter', $1,
|
||||
'{"remote_url":"https://github.com/example/repo","federated":true}'::jsonb)`,
|
||||
[userTree],
|
||||
);
|
||||
|
||||
let threw: SourceOpError | null = null;
|
||||
try {
|
||||
await recloneIfMissing(engine, 'gstack-code-app-abc');
|
||||
} catch (e) {
|
||||
threw = e as SourceOpError;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(SourceOpError);
|
||||
expect(threw?.code).toBe('unmanaged_path');
|
||||
// The working tree and its sentinel MUST survive untouched.
|
||||
expect(existsSync(userTree)).toBe(true);
|
||||
expect(existsSync(sentinel)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('sync-shape: same refusal surfaces before any filesystem op', async () => {
|
||||
await withEnv2(async () => {
|
||||
// A degraded unowned path (the path does not exist at all → missing).
|
||||
const ghost = join(FAKE_GIT_DIR, 'ghost-tree');
|
||||
rmSync(ghost, { recursive: true, force: true });
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config)
|
||||
VALUES ('ghost', 'g', $1,
|
||||
'{"remote_url":"https://github.com/example/repo"}'::jsonb)`,
|
||||
[ghost],
|
||||
);
|
||||
await expect(recloneIfMissing(engine, 'ghost')).rejects.toThrow(/unmanaged_path|not a clone gbrain created/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('recloneIfMissing — symlink TOCTOU + EXDEV-safe swap', () => {
|
||||
test('symlink at an owned default-location path → symlink_escape, target untouched', async () => {
|
||||
await withEnv2(async () => {
|
||||
// Owned by equality: local_path === defaultCloneDir(id). But the path is a
|
||||
// symlink to a real dir → reclone must refuse rather than rename through it.
|
||||
const id = 'sym-owned';
|
||||
const target = join(FAKE_GIT_DIR, 'sym-target');
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
mkdirSync(target, { recursive: true });
|
||||
const targetSentinel = join(target, 'precious.txt');
|
||||
writeFileSync(targetSentinel, 'do not delete');
|
||||
|
||||
mkdirSync(CLONE_ROOT, { recursive: true });
|
||||
const clonePath = defaultCloneDir(id); // = CLONE_ROOT/sym-owned
|
||||
symlinkSync(target, clonePath);
|
||||
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config)
|
||||
VALUES ($1, 's', $2,
|
||||
'{"remote_url":"https://github.com/example/repo"}'::jsonb)`,
|
||||
[id, clonePath],
|
||||
);
|
||||
|
||||
let threw: SourceOpError | null = null;
|
||||
try {
|
||||
await recloneIfMissing(engine, id);
|
||||
} catch (e) {
|
||||
threw = e as SourceOpError;
|
||||
}
|
||||
expect(threw?.code).toBe('symlink_escape');
|
||||
// Symlink target and its contents survive.
|
||||
expect(existsSync(targetSentinel)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('owned no-git clone reclones; no .gbrain-reclone-* / .old-* residue left', async () => {
|
||||
await withEnv2(async () => {
|
||||
const row = await addSource(engine, {
|
||||
id: 'swap-clean',
|
||||
remoteUrl: 'https://github.com/example/repo',
|
||||
});
|
||||
// Degrade to no-git so reclone fires.
|
||||
rmSync(join(row.local_path!, '.git'), { recursive: true, force: true });
|
||||
|
||||
const recloned = await recloneIfMissing(engine, 'swap-clean');
|
||||
expect(recloned).toBe(true);
|
||||
expect(existsSync(join(row.local_path!, '.git'))).toBe(true);
|
||||
|
||||
// Parent (CLONE_ROOT) must hold no swap residue.
|
||||
const residue = readdirSync(CLONE_ROOT).filter(
|
||||
(e) => e.startsWith('.gbrain-reclone-') || e.includes('.old-'),
|
||||
);
|
||||
expect(residue).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sources restore — unowned source (CV3)', () => {
|
||||
test('restore of an unowned remote_url row: DB row restored, tree survives, correct guidance', async () => {
|
||||
await withEnv2(async () => {
|
||||
// Archived federated row: remote_url set, local_path = a live user tree
|
||||
// (no .git, no managed_clone marker). Restore calls recloneIfMissing,
|
||||
// which now throws unmanaged_path; runRestore must catch it, keep the tree,
|
||||
// and NOT print the misleading "missing clone, try sync to recover" hint.
|
||||
const userTree = join(FAKE_GIT_DIR, 'restore-user-tree');
|
||||
rmSync(userTree, { recursive: true, force: true });
|
||||
mkdirSync(userTree, { recursive: true });
|
||||
const sentinel = join(userTree, 'KEEP_ME.txt');
|
||||
writeFileSync(sentinel, 'live repo');
|
||||
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, archived)
|
||||
VALUES ('restore-unowned', 'flutter', $1,
|
||||
'{"remote_url":"https://github.com/example/repo","federated":false}'::jsonb,
|
||||
true)`,
|
||||
[userTree],
|
||||
);
|
||||
|
||||
const errs: string[] = [];
|
||||
const origErr = console.error;
|
||||
console.error = (...a: unknown[]) => { errs.push(a.join(' ')); };
|
||||
try {
|
||||
// Real CLI dispatch → runRestore. Must not throw.
|
||||
await runSources(engine, ['restore', 'restore-unowned']);
|
||||
} finally {
|
||||
console.error = origErr;
|
||||
}
|
||||
|
||||
// DB row un-archived (restore succeeded).
|
||||
const rows = await engine.executeRaw<{ archived: boolean }>(
|
||||
`SELECT archived FROM sources WHERE id = 'restore-unowned'`,
|
||||
);
|
||||
expect(rows[0].archived).toBe(false);
|
||||
// Working tree untouched.
|
||||
expect(existsSync(userTree)).toBe(true);
|
||||
expect(existsSync(sentinel)).toBe(true);
|
||||
// Guidance is the read-only one, NOT the misleading "missing clone" hint.
|
||||
const joined = errs.join('\n');
|
||||
expect(joined).toMatch(/read-only/);
|
||||
expect(joined).not.toMatch(/on-disk clone is missing/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSource --url — writes ownership marker', () => {
|
||||
test('config carries managed_clone:true', async () => {
|
||||
await withEnv2(async () => {
|
||||
await addSource(engine, {
|
||||
id: 'marked',
|
||||
remoteUrl: 'https://github.com/example/repo',
|
||||
});
|
||||
const rows = await engine.executeRaw<{ config: unknown }>(
|
||||
`SELECT config FROM sources WHERE id = 'marked'`,
|
||||
);
|
||||
const cfg =
|
||||
typeof rows[0].config === 'string'
|
||||
? JSON.parse(rows[0].config as string)
|
||||
: (rows[0].config as Record<string, unknown>);
|
||||
expect(cfg.managed_clone).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('--clone-dir clone (external path) is owned via marker and reclones', async () => {
|
||||
await withEnv2(async () => {
|
||||
const externalClone = join(FAKE_GIT_DIR, 'custom-clone-dir');
|
||||
rmSync(externalClone, { recursive: true, force: true });
|
||||
const row = await addSource(engine, {
|
||||
id: 'cdir',
|
||||
remoteUrl: 'https://github.com/example/repo',
|
||||
cloneDir: externalClone,
|
||||
});
|
||||
expect(row.local_path).toBe(externalClone);
|
||||
// Remove the leaf → reclone must succeed (owned via marker, NOT containment).
|
||||
rmSync(externalClone, { recursive: true, force: true });
|
||||
const recloned = await recloneIfMissing(engine, 'cdir');
|
||||
expect(recloned).toBe(true);
|
||||
expect(existsSync(join(externalClone, '.git'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPathContained — symlink-safe confinement helper (exported for reuse)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -254,4 +254,34 @@ describe('performSync re-clone branch (driven by sync.ts:320 logic)', () => {
|
||||
expect(recloned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// #1881: the sync branch must NOT re-clone over an unowned user working tree.
|
||||
test('unowned local_path (remote_url, no marker): refuses, working tree survives', async () => {
|
||||
await withEnv({ GBRAIN_HOME, PATH: fakePath() }, async () => {
|
||||
// Federated row shape created by the gstack orchestrator: remote_url set,
|
||||
// local_path = a live user tree OUTSIDE the clone root, no managed_clone.
|
||||
const userTree = join(FAKE_GIT_DIR, 'sync-user-tree');
|
||||
rmSync(userTree, { recursive: true, force: true });
|
||||
mkdirSync(userTree, { recursive: true });
|
||||
const sentinel = join(userTree, 'KEEP_ME.txt');
|
||||
writeFileSync(sentinel, 'live repo');
|
||||
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config)
|
||||
VALUES ('sync-unowned', 'flutter', $1,
|
||||
'{"remote_url":"https://github.com/example/repo","federated":true}'::jsonb)`,
|
||||
[userTree],
|
||||
);
|
||||
|
||||
// no-git → the sync branch would historically call recloneIfMissing →
|
||||
// rm the tree. Now it must throw unmanaged_path and leave the tree intact.
|
||||
const state = validateRepoState(userTree, 'https://github.com/example/repo');
|
||||
expect(state).toBe('no-git');
|
||||
await expect(recloneIfMissing(engine, 'sync-unowned')).rejects.toThrow(
|
||||
/unmanaged_path|not a clone gbrain created/,
|
||||
);
|
||||
expect(existsSync(userTree)).toBe(true);
|
||||
expect(existsSync(sentinel)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user