mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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>
216 lines
10 KiB
TypeScript
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 };
|
|
}
|
|
}
|