mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +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
@@ -394,7 +394,14 @@ async function runRestore(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
console.log(` re-cloned from remote_url (clone dir was missing).`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof SourceOpError) {
|
||||
if (e instanceof SourceOpError && e.code === 'unmanaged_path') {
|
||||
// #1881: local_path is the user's own working tree, not a clone gbrain
|
||||
// created. gbrain won't re-clone over it, and `gbrain sync` will refuse it
|
||||
// too — so the generic "missing clone, try sync to recover" guidance below
|
||||
// would be actively misleading. Surface the real situation instead.
|
||||
console.error(` WARN: ${e.message}`);
|
||||
console.error(` The DB row is restored; gbrain syncs this path read-only.`);
|
||||
} else if (e instanceof SourceOpError) {
|
||||
console.error(` WARN: could not re-clone: ${e.message}`);
|
||||
console.error(` The DB row is restored but the on-disk clone is missing.`);
|
||||
console.error(` Try \`gbrain sync --source ${id}\` to recover, or remove + re-add.`);
|
||||
|
||||
+18
-3
@@ -1084,9 +1084,11 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
if (opts.sourceId) {
|
||||
serr(`[gbrain phase] sync.validate_repo_state`);
|
||||
const { validateRepoState } = await import('../core/git-remote.ts');
|
||||
const { recloneIfMissing } = await import('../core/sources-ops.ts');
|
||||
const cfgRows = await engine.executeRaw<{ config: unknown }>(
|
||||
`SELECT config FROM sources WHERE id = $1`,
|
||||
const { recloneIfMissing, isOwnedClone, unownedHint } = await import(
|
||||
'../core/sources-ops.ts'
|
||||
);
|
||||
const cfgRows = await engine.executeRaw<{ local_path: string | null; config: unknown }>(
|
||||
`SELECT local_path, config FROM sources WHERE id = $1`,
|
||||
[opts.sourceId],
|
||||
);
|
||||
const cfg =
|
||||
@@ -1095,13 +1097,26 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
: ((cfgRows[0]?.config ?? {}) as Record<string, unknown>);
|
||||
const remoteUrl = typeof cfg.remote_url === 'string' ? cfg.remote_url : null;
|
||||
if (remoteUrl) {
|
||||
const ownSrc = {
|
||||
id: opts.sourceId,
|
||||
local_path: cfgRows[0]?.local_path ?? repoPath,
|
||||
config: cfg,
|
||||
};
|
||||
const state = validateRepoState(repoPath, remoteUrl);
|
||||
switch (state) {
|
||||
case 'healthy':
|
||||
// No per-sync warning for an unowned-but-healthy source — it would
|
||||
// spam every sync. The misconfig is surfaced by the doctor check
|
||||
// (TODO1) instead. Healthy unowned paths sync read-only and are safe.
|
||||
break;
|
||||
case 'missing':
|
||||
case 'no-git':
|
||||
case 'not-a-dir':
|
||||
// #1881: only re-clone a clone gbrain owns. An unowned local_path
|
||||
// (the user's working tree) is refused loudly, never deleted.
|
||||
if (!isOwnedClone(ownSrc)) {
|
||||
throw new Error(unownedHint(ownSrc, state));
|
||||
}
|
||||
serr(
|
||||
`[gbrain] auto-recovery: re-cloning "${opts.sourceId}" (clone state: ${state}).`,
|
||||
);
|
||||
|
||||
+142
-14
@@ -38,7 +38,7 @@
|
||||
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, lstatSync } from 'fs';
|
||||
import { realpathSync } from 'fs';
|
||||
import { join, dirname, resolve as resolvePath } from 'path';
|
||||
import { join, dirname, basename, resolve as resolvePath } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import {
|
||||
@@ -66,7 +66,8 @@ export type SourceOpErrorCode =
|
||||
| 'not_found'
|
||||
| 'protected_id'
|
||||
| 'clone_dir_outside_gbrain'
|
||||
| 'symlink_escape';
|
||||
| 'symlink_escape'
|
||||
| 'unmanaged_path';
|
||||
|
||||
export class SourceOpError extends Error {
|
||||
constructor(
|
||||
@@ -253,6 +254,70 @@ export function isPathContained(child: string, parent: string): boolean {
|
||||
return resolvedChild === resolvedParent || resolvedChild.startsWith(parentWithSep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Did gbrain CREATE this clone (so re-clone/delete is safe)? Ownership, NOT
|
||||
* path-containment — a user-supplied working tree is NEVER owned, even if it
|
||||
* happens to sit under $GBRAIN_HOME. This is the #1881 guard: recloneIfMissing
|
||||
* deletes local_path, so it must only ever fire on a clone gbrain owns.
|
||||
*
|
||||
* Ownership is proven by either:
|
||||
* 1. config.managed_clone === true — written by addSource's --url path
|
||||
* (covers both default-location and --clone-dir clones), OR
|
||||
* 2. local_path === defaultCloneDir(id) — back-compat for clones created
|
||||
* before the marker existed (gbrain's default location), via exact
|
||||
* normalized-path equality (symlink-free, so none of isPathContained's
|
||||
* symlinked-parent / lexical-escape edge cases apply).
|
||||
*
|
||||
* Everything else is fail-closed (NOT owned → refuse to touch): the bug's
|
||||
* federated row (remote_url + a user tree), and pre-marker --clone-dir clones
|
||||
* (rare, local-only) which are byte-for-byte indistinguishable from it. Those
|
||||
* must be re-added to regain auto-reclone — the correct trade-off when ownership
|
||||
* is unprovable.
|
||||
*/
|
||||
export function isOwnedClone(src: {
|
||||
id: string;
|
||||
local_path: string | null;
|
||||
config: unknown;
|
||||
}): boolean {
|
||||
if (!src.local_path) return false;
|
||||
const cfg =
|
||||
typeof src.config === 'string'
|
||||
? (JSON.parse(src.config) as Record<string, unknown>)
|
||||
: ((src.config ?? {}) as Record<string, unknown>);
|
||||
if (cfg.managed_clone === true) return true;
|
||||
return resolvePath(src.local_path) === resolvePath(defaultCloneDir(src.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery hint for an unowned source with a remote_url. Splits guidance by
|
||||
* on-disk state: a healthy unowned path syncs read-only (just drop remote_url),
|
||||
* but a degraded one (missing/no-git/not-a-dir) cannot be recovered by dropping
|
||||
* remote_url — that would only defer the failure to the "Not a git repository"
|
||||
* check. Shared by the core SourceOpError and the sync.ts CLI error so they read
|
||||
* identically.
|
||||
*/
|
||||
export function unownedHint(
|
||||
src: { id: string; local_path: string | null },
|
||||
state: RepoState,
|
||||
): string {
|
||||
const path = src.local_path ?? '(none)';
|
||||
if (state === 'healthy') {
|
||||
return (
|
||||
`Source "${src.id}" has config.remote_url set but local_path ${path} is not a ` +
|
||||
`clone gbrain created. gbrain syncs it read-only and will never re-clone or delete ` +
|
||||
`it. To silence this, drop config.remote_url, or re-register with --url so gbrain ` +
|
||||
`owns the clone.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`Source "${src.id}" has config.remote_url set but local_path ${path} is not a clone ` +
|
||||
`gbrain created and is not a usable git repo (state: ${state}). gbrain will NOT ` +
|
||||
`re-clone over it (it is your working tree, not a gbrain-managed mirror). Restore the ` +
|
||||
`directory yourself, or remove + re-add the source with --url to let gbrain manage the ` +
|
||||
`clone.`
|
||||
);
|
||||
}
|
||||
|
||||
// ── addSource ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addSource(
|
||||
@@ -327,7 +392,14 @@ export async function addSource(
|
||||
throw e;
|
||||
}
|
||||
|
||||
const config: Record<string, unknown> = { remote_url: parsedUrl.url };
|
||||
// managed_clone:true is the ownership marker (#1881). It authorizes
|
||||
// recloneIfMissing to rm+replace this clone — gbrain created it, here or at
|
||||
// a --clone-dir path. A user-tree row (created by an external INSERT, no
|
||||
// --url) never carries this, so it can never be deleted by reclone.
|
||||
const config: Record<string, unknown> = {
|
||||
remote_url: parsedUrl.url,
|
||||
managed_clone: true,
|
||||
};
|
||||
if (opts.federated !== null && opts.federated !== undefined) {
|
||||
config.federated = opts.federated;
|
||||
}
|
||||
@@ -698,9 +770,22 @@ export async function recloneIfMissing(
|
||||
const state = validateRepoState(src.local_path, remoteUrl);
|
||||
if (state === 'healthy') return false;
|
||||
|
||||
// Re-clone via temp + rename, mirroring addSource's atomicity contract.
|
||||
const tempDir = makeTempCloneDir(id);
|
||||
mkdirSync(dirname(tempDir), { recursive: true });
|
||||
// #1881 ownership guard — abort BEFORE any filesystem op. recloneIfMissing
|
||||
// deletes local_path; gbrain may only do that to a clone it created, never a
|
||||
// user working tree. A row with remote_url + an unowned local_path (the
|
||||
// gstack-orchestrator federated shape) is refused here, loudly, untouched.
|
||||
if (!isOwnedClone(src)) {
|
||||
throw new SourceOpError('unmanaged_path', unownedHint(src, state));
|
||||
}
|
||||
|
||||
// EXDEV-safe atomic reclone. Clone into a SIBLING temp of local_path (not the
|
||||
// shared clones/.tmp, which can be on a different mount than a --clone-dir
|
||||
// target → EXDEV → "deleted but not recloned"). Then swap: move old aside →
|
||||
// move new in → drop old, so local_path is never left missing-and-unrecoverable.
|
||||
const parent = dirname(src.local_path);
|
||||
mkdirSync(parent, { recursive: true });
|
||||
const rand = randomBytes(6).toString('hex');
|
||||
const tempDir = join(parent, `.gbrain-reclone-${basename(src.local_path)}-${rand}`);
|
||||
try {
|
||||
cloneRepo(remoteUrl, tempDir);
|
||||
} catch (e) {
|
||||
@@ -711,19 +796,62 @@ export async function recloneIfMissing(
|
||||
throw e;
|
||||
}
|
||||
|
||||
// If the local_path partially exists (e.g., empty dir, file-not-dir), nuke
|
||||
// it before the rename so renameSync doesn't fail on a non-empty target.
|
||||
rmSync(src.local_path, { recursive: true, force: true });
|
||||
mkdirSync(dirname(src.local_path), { recursive: true });
|
||||
try {
|
||||
renameSync(tempDir, src.local_path);
|
||||
} catch (e) {
|
||||
// TOCTOU re-check immediately before the destructive move: re-confirm
|
||||
// ownership AND reject a symlink leaf swapped in after the entry check (never
|
||||
// rm-rf / rename through a symlink).
|
||||
if (!isOwnedClone(src)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
throw new SourceOpError('unmanaged_path', unownedHint(src, state));
|
||||
}
|
||||
let aside: string | null = null;
|
||||
try {
|
||||
if (existsSync(src.local_path)) {
|
||||
// Symlink leaf guard: never rename/rm *through* a symlinked leaf — that's
|
||||
// the TOCTOU swap-in vector (an attacker plants a symlink at local_path
|
||||
// between the entry ownership check and this rename). An owned clone's leaf
|
||||
// is a real dir gbrain created; a symlink here means tamper, so fail closed.
|
||||
// (Symlinked ANCESTORS are intentionally NOT rejected here: for an owned
|
||||
// clone gbrain created the dir at this path — cloneRepo refuses a non-empty
|
||||
// dest, so a pre-existing user tree can never become an owned clone — and a
|
||||
// realpath-chain check false-positives on ubiquitous system symlinks like
|
||||
// macOS /var -> /private/var. The residual DB-trust risk, a forged
|
||||
// managed_clone marker on an arbitrary path, is tracked as a TODO and is
|
||||
// not closable by a path check.)
|
||||
if (lstatSync(src.local_path).isSymbolicLink()) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
throw new SourceOpError(
|
||||
'symlink_escape',
|
||||
`Refusing to re-clone "${id}": local_path ${src.local_path} is a symlink.`,
|
||||
);
|
||||
}
|
||||
aside = `${src.local_path}.old-${rand}`;
|
||||
renameSync(src.local_path, aside); // same fs (sibling) — no EXDEV
|
||||
}
|
||||
renameSync(tempDir, src.local_path); // same fs — no EXDEV
|
||||
} catch (e) {
|
||||
// Best-effort restore of the original if the swap left local_path missing.
|
||||
if (aside && !existsSync(src.local_path)) {
|
||||
try {
|
||||
renameSync(aside, src.local_path);
|
||||
} catch {
|
||||
/* original kept at `aside`; surfaced via the thrown error below */
|
||||
}
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
if (e instanceof SourceOpError) throw e;
|
||||
// If the original is still parked at `aside` (restore failed), tell the user
|
||||
// exactly where it is — otherwise a "cleanup the failed reclone" reflex would
|
||||
// delete their only copy.
|
||||
const asideNote =
|
||||
aside && existsSync(aside)
|
||||
? ` Your original clone is preserved at ${aside} — restore it manually; do not delete it.`
|
||||
: '';
|
||||
throw new SourceOpError(
|
||||
'rename_failed',
|
||||
`Could not move re-cloned repo to ${src.local_path}: ${(e as Error).message}`,
|
||||
`Could not move re-cloned repo to ${src.local_path}: ${(e as Error).message}.${asideNote}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
if (aside) rmSync(aside, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user