Files
gbrain/src/core/write-through.ts
T
c571bf82de fix(write-through): guard case-insensitive filesystem collisions before atomic write (#2831) (#3119)
On macOS/Windows (case-folding filesystems), the write-through rename
silently clobbered a differently-cased file already occupying the target
path (uncontrolled repo files like README.md vs slug readme, or unicode
normalization variants between slugs). Refuse with
skipped: 'case_insensitive_collision' when the path exists on disk but no
exactly-named directory entry does; exact-case updates fall through and
case-sensitive filesystems are unaffected.

Fixes #2831

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:42:50 -07:00

216 lines
10 KiB
TypeScript

/**
* Shared disk write-through for the canonical ingestion path.
*
* After a page row lands in the DB (via importFromContent / putPage), this
* renders the row to markdown via `serializePageToMarkdown` and writes it to
* `sync.repo_path` so the brain repo has a committable `.md` artifact that
* round-trips cleanly through `gbrain sync`. The file is rendered FROM the DB
* row, so the two sinks cannot diverge.
*
* Extracted from the v0.38 `put_page` write-through (operations.ts) so the
* `put_page` op AND `gbrain brainstorm/lsd --save` share one implementation
* instead of hand-rolling parallel (and divergent) copies. The extraction also
* upgraded the write to be ATOMIC — the original used a bare `writeFileSync`
* into a live git tree that `gbrain sync` / autopilot actively walk, so a crash
* mid-write left a partial `.md` that sync would fail to parse. We now write to
* a unique temp sibling and `rename` into place (rename is atomic on the same
* filesystem), matching the `.tmp + rename` convention used by
* import-checkpoint.ts / op-checkpoint.ts.
*
* Trust gating (subagent sandbox, dry-run) stays at the CALLER — this helper
* only does "row exists + repo is a real dir → render + atomic write".
*/
import { existsSync, statSync, mkdirSync, writeFileSync, renameSync, unlinkSync, readdirSync } from 'fs';
import { basename, dirname, join } from 'path';
import { randomBytes } from 'crypto';
import type { BrainEngine } from './engine.ts';
import { serializePageToMarkdown, resolvePageFilePath } from './markdown.ts';
import { isWriteTargetContained } from './path-confine.ts';
import { isDurabilityHardened, commitWriteThroughFile } from './brain-repo-durability.ts';
/** Minimal logger surface — structurally compatible with operations.ts `Logger`. */
export interface WriteThroughLogger {
warn(msg: string): void;
}
export interface WriteThroughResult {
written: boolean;
path?: string;
/**
* True when the write was also committed to git (#2426). Only attempted on
* repos hardened via `gbrain sources harden` (durability hook installed);
* the hook then background-pushes the commit. Best-effort — a false/absent
* value never blocks the write.
*/
committed?: boolean;
/**
* Non-error reasons the file was not written:
* - no_repo_configured: the resolved target (source `local_path` or, for a
* sole-source brain, `sync.repo_path`) is unset (DB-only by design).
* - repo_not_found: target set but missing / not a directory.
* - source_repo_belongs_to_other_source: the assigned source has no
* `local_path`, and `sync.repo_path` is another source's own working tree
* — #2018: writing here would pollute that sibling's repo, so we skip.
* - page_not_found_after_write: the DB row isn't readable back (the caller's
* DB write failed or targeted a different source).
* - path_escapes_source_root: the computed file path resolves outside the
* source's working tree (hostile slug row / symlinked subtree) — refused.
* - case_insensitive_collision: on a case-insensitive filesystem
* (macOS/Windows default), the target directory already holds a
* differently-cased entry that the FS folds onto this page's file, so
* writing would silently clobber the OTHER slug's file (#2831) — refused.
*/
skipped?: 'no_repo_configured' | 'repo_not_found' | 'source_repo_belongs_to_other_source' | 'page_not_found_after_write' | 'path_escapes_source_root' | 'case_insensitive_collision';
/** Set when the render/write/rename itself threw (EACCES, ENOTDIR, disk full). */
error?: string;
}
export interface WritePageThroughOpts {
sourceId?: string;
/** Merged over the page's own frontmatter at render time (e.g. provenance). */
frontmatterOverrides?: Record<string, unknown>;
logger?: WriteThroughLogger;
}
/**
* Render the DB row for `slug` to markdown and atomically write it under
* `sync.repo_path`. Never throws — failures are reported via the result's
* `skipped` / `error` fields (the DB write is the durable sink; the file is
* best-effort and reconciled by the next `gbrain sync`).
*/
export async function writePageThrough(
engine: BrainEngine,
slug: string,
opts: WritePageThroughOpts = {},
): Promise<WriteThroughResult> {
const sourceId = opts.sourceId ?? 'default';
try {
// #2018: pick the disk target so a page is NEVER written into a different
// source's working tree. Two legitimate topologies, plus the leak guard:
// 1. The assigned source has its OWN `local_path` (a separate working
// tree) → write at that tree's root (matches how `scanOneSource` reads
// it back; never nested under `.sources/`).
// 2. No per-source `local_path` → nest under the host repo
// (`sync.repo_path`): default at the root, non-default under
// `.sources/<id>/` (the established multi-source layout).
// 3. LEAK GUARD: if `sync.repo_path` is literally ANOTHER source's own
// `local_path`, nesting this page there would pollute that sibling's
// git repo (the reported bug). Skip instead.
let filePath: string;
let writeRoot: string;
const srcRows = await engine.executeRaw<{ local_path: string | null }>(
`SELECT local_path FROM sources WHERE id = $1`,
[sourceId],
);
const sourceLocalPath = srcRows[0]?.local_path ?? null;
if (sourceLocalPath) {
if (!existsSync(sourceLocalPath) || !statSync(sourceLocalPath).isDirectory()) {
return { written: false, skipped: 'repo_not_found' };
}
filePath = join(sourceLocalPath, `${slug}.md`);
writeRoot = sourceLocalPath;
} else {
const repoPath = await engine.getConfig('sync.repo_path');
if (!repoPath) {
return { written: false, skipped: 'no_repo_configured' };
}
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
return { written: false, skipped: 'repo_not_found' };
}
// Leak guard: refuse to write into a path that is some OTHER source's
// own working tree (#2018).
const collide = await engine.executeRaw<{ one: number }>(
`SELECT 1 AS one FROM sources WHERE id <> $1 AND local_path = $2 LIMIT 1`,
[sourceId, repoPath],
);
if (collide.length > 0) {
return { written: false, skipped: 'source_repo_belongs_to_other_source' };
}
filePath = resolvePageFilePath(repoPath, slug, sourceId);
writeRoot = repoPath;
}
// Defense-in-depth (#1647-slug / codex #6): confirm the computed file path
// stays within the source's working tree before any mkdir/write. validateSlug
// already rejects `..`/backslash/control/%2e in the slug at write time, so
// this guards a pre-existing hostile row or a symlinked intermediate dir
// under the source tree from escaping to an arbitrary filesystem location.
if (!isWriteTargetContained(filePath, writeRoot)) {
return { written: false, skipped: 'path_escapes_source_root' };
}
const writtenPage = await engine.getPage(slug, { sourceId });
if (!writtenPage) {
return { written: false, skipped: 'page_not_found_after_write' };
}
const tags = await engine.getTags(slug, { sourceId });
const md = serializePageToMarkdown(writtenPage, tags, {
frontmatterOverrides: opts.frontmatterOverrides,
});
// #2831: two distinct DB slugs differing only by case (FOO vs foo) resolve
// to the SAME file on a case-insensitive filesystem — the second write
// would silently clobber the first slug's artifact. Refuse when the target
// dir holds a differently-cased entry that the FS folds onto our path:
// exact-case entry present → normal update, falls through; on a
// case-sensitive FS the variant path doesn't exist, so the guard is a
// no-op there.
const dir = dirname(filePath);
if (existsSync(dir)) {
const base = basename(filePath);
const entries = readdirSync(dir);
if (!entries.includes(base) && existsSync(filePath)) {
// The path exists on disk but no exactly-named entry does → the FS
// folded the name (case, or unicode normalization on APFS) onto a
// different slug's file.
const clash =
entries.find((e) => e.toLowerCase() === base.toLowerCase()) ?? '(normalization variant)';
opts.logger?.warn(
`[write-through] case-insensitive collision for ${slug}: '${clash}' already occupies ${filePath} — file not written (DB row is intact)`,
);
return { written: false, skipped: 'case_insensitive_collision' };
}
}
mkdirSync(dirname(filePath), { recursive: true });
// Atomic write: unique temp sibling + rename. Unique name (pid + random)
// so two concurrent saves to the same target can't clobber each other's
// temp file. Clean up the temp on any failure so we never leak a stray
// `.tmp` next to the real file.
const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
try {
writeFileSync(tmpPath, md, 'utf8');
renameSync(tmpPath, filePath);
} catch (writeErr) {
try {
if (existsSync(tmpPath)) unlinkSync(tmpPath);
} catch {
// best-effort cleanup; surface the original write error below
}
throw writeErr;
}
// #2426: on a durability-hardened repo (user ran `gbrain sources harden`),
// commit the artifact so it reaches git — pre-fix, write-through content
// stayed uncommitted forever: never pushed, `last_sync_at` frozen, and
// silently deleted by a later `sync --full` delete-reconcile. The local
// post-commit hook background-pushes the commit. Best-effort: a commit
// failure never fails the write (the DB row + file are the durable sinks).
let committed = false;
try {
if (isDurabilityHardened(writeRoot)) {
committed = commitWriteThroughFile(writeRoot, filePath, slug);
}
} catch { /* best-effort */ }
return { written: true, path: filePath, ...(committed ? { committed } : {}) };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
opts.logger?.warn(`[write-through] failed for ${slug}: ${msg}`);
return { written: false, error: msg };
}
}