/** * 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 } from 'fs'; import { 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. */ skipped?: 'no_repo_configured' | 'repo_not_found' | 'source_repo_belongs_to_other_source' | 'page_not_found_after_write' | 'path_escapes_source_root'; /** 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; 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 { 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//` (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, }); 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 }; } }