diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 70d1873205a..fd4bffff583 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -21,6 +21,7 @@ import { import { createHash } from 'node:crypto'; import { LibraryBindingStore } from '../libraryBinding.js'; import { LibraryVault } from '../libraryVault.js'; +import { initCustomLibraryTree, openExistingCustomLibrary } from '../libraryDirFd.js'; import { createLibraryDbCore, type SqliteDatabaseConstructor } from '../libraryDbCore.js'; import { LibrarySqlService } from '../librarySqlService.js'; import { @@ -364,6 +365,340 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'a.txt'))).toBe(false); }); + it('cached custom session: missing root stays unavailable without mkdir; same disk recovers; recreated path is binding-moved', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + expect(open.location).toBe('custom'); + const live = open as unknown as { libraryGeneration: number; libraryIdentity: string }; + expect(live.libraryGeneration).toBe(1); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + expect(fs.existsSync(path.join(customRoot, 'keep.txt'))).toBe(true); + + const parked = `${candidate}.parked`; + await fs.promises.rename(candidate, parked); + expect(fs.existsSync(customRoot)).toBe(false); + + const missingOpen = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!missingOpen.ok || missingOpen.op !== 'open') throw new Error(JSON.stringify(missingOpen)); + expect(missingOpen.state).toBe('unavailable'); + expect(missingOpen.reason).toBe('disk-missing'); + expect(missingOpen.location).toBe('custom'); + const missingStatus = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!missingStatus.ok || missingStatus.op !== 'status') throw new Error(JSON.stringify(missingStatus)); + expect(missingStatus.state).toBe('unavailable'); + expect(missingStatus.reason).toBe('disk-missing'); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(customRoot)).toBe(false); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'empty.txt'))).toBe(false); + + await fs.promises.rename(parked, candidate); + const recovered = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!recovered.ok || recovered.op !== 'open') throw new Error(JSON.stringify(recovered)); + expect(recovered.state).toBe('ready'); + expect(recovered.reason).toBeUndefined(); + expect(recovered.location).toBe('custom'); + expect((recovered as unknown as { libraryIdentity: string }).libraryIdentity).toBe(live.libraryIdentity); + expect((recovered as unknown as { libraryGeneration: number }).libraryGeneration).toBe(live.libraryGeneration); + const recoveredStatus = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!recoveredStatus.ok || recoveredStatus.op !== 'status') throw new Error(JSON.stringify(recoveredStatus)); + expect(recoveredStatus.state).toBe('ready'); + const reread = await slot.handleLibraryRequest(GHOST_ID, { op: 'read', path: 'keep.txt' }); + if (!reread.ok || reread.op !== 'read') throw new Error(JSON.stringify(reread)); + expect(reread.content).toBe('keep-me'); + const retryWrite = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'retry.txt', content: 'after-restore' }); + expect(retryWrite.ok).toBe(true); + expect(fs.existsSync(path.join(customRoot, 'retry.txt'))).toBe(true); + + if (process.platform === 'win32') return; + const replaced = `${candidate}.replaced`; + await fs.promises.rename(candidate, replaced); + await fs.promises.mkdir(candidate, { recursive: true }); + const moved = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!moved.ok || moved.op !== 'open') throw new Error(JSON.stringify(moved)); + expect(moved.state).toBe('unavailable'); + expect(moved.reason).toBe('binding-moved'); + expect(moved.location).toBe('custom'); + const movedWrite = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'wrong-root.txt', content: 'nope' }); + expect(movedWrite).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(fs.existsSync(path.join(candidate, GHOST_ID, 'wrong-root.txt'))).toBe(false); + expect(fs.existsSync(path.join(candidate, GHOST_ID, 'keep.txt'))).toBe(false); + }); + + it('delayed resolveLibraryRoot: stale custom after parent rename is disk-missing without recreating empty library', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const customRoot = path.join(candidate, GHOST_ID); + expect(fs.existsSync(path.join(customRoot, 'keep.txt'))).toBe(true); + const parked = `${candidate}.parked`; + resolveLibraryRoot.mockImplementation(async (ghostId: string) => { + const resolution = await LibraryBindingStore.prototype.resolveLibraryRoot.call(bindingStore, ghostId); + if (resolution.kind === 'custom' && resolution.root !== null) { + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return resolution; + }); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(customRoot)).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + const blocked = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'empty.txt', content: 'nope' }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + }); + + it('stale resolve 后 rename+同路径新 inode:不得建空库或授权错误根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + const parked = `${candidate}.parked`; + resolveLibraryRoot.mockImplementation(async (ghostId: string) => { + const resolution = await LibraryBindingStore.prototype.resolveLibraryRoot.call(bindingStore, ghostId); + if (resolution.kind === 'custom' && resolution.root !== null && fs.existsSync(candidate)) { + await fs.promises.rename(candidate, parked); + await fs.promises.mkdir(candidate, { recursive: true }); + } + return resolution; + }); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('binding-moved'); + expect(fs.existsSync(path.join(candidate, GHOST_ID, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(after.authorizedReadonly).toBe(false); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); + }); + + it('最后一次 inspect 后骨架 mkdir 前父目录被移走: disk-missing, keep 留 parked, 不授权空根', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + await slot.disposeAll(); + const parked = `${candidate}.parked`; + let injected = false; + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + openExistingCustom: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + } + return openExistingCustomLibrary(req); + }, + })); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(injected).toBe(true); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('disk-missing'); + expect(after.authorizedReadonly).toBe(false); + expect(fs.existsSync(candidate)).toBe(false); + expect(fs.existsSync(path.join(candidate, GHOST_ID, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + expect(fs.existsSync(path.join(defaultRootBase, GHOST_ID, 'keep.txt'))).toBe(false); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(path.join(candidate, GHOST_ID)); + }); + + it('最后一次成功 inspect 后换成同路径新 inode:不得 mkdir/meta/授权替换根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.state).toBe('ready'); + const keep = await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + expect(keep.ok).toBe(true); + await slot.disposeAll(); + const parked = `${candidate}.parked`; + const custom = path.join(candidate, GHOST_ID); + let injected = false; + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + openExistingCustom: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + await fs.promises.mkdir(candidate); + } + return openExistingCustomLibrary(req); + }, + })); + const after = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!after.ok || after.op !== 'open') throw new Error(JSON.stringify(after)); + expect(injected).toBe(true); + expect(after.state).toBe('unavailable'); + expect(after.reason).toBe('binding-moved'); + expect(after.authorizedReadonly).toBe(false); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, GHOST_ID, 'keep.txt'))).toBe(true); + const extraRoots = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID).map((call) => call[1]); + expect(extraRoots.at(-1) ?? 'none').not.toBe(custom); + }); + + it('D: helper 后 tmp readdir 换根不得写 usage.json 且拒授权替换根', async () => { + if (process.platform === 'win32') return; + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const parked = `${candidate}.parked`; + const custom = path.join(candidate, GHOST_ID); + let afterInit = false; + let swapped = false; + const origReaddir = fs.promises.readdir.bind(fs.promises); + const origRename = fs.promises.rename.bind(fs.promises); + const origMkdir = fs.promises.mkdir.bind(fs.promises); + const origWriteFile = fs.promises.writeFile.bind(fs.promises); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (afterInit && !swapped && dest.includes(`${path.sep}${GHOST_ID}${path.sep}.cindy-library${path.sep}tmp`)) { + swapped = true; + if (fs.existsSync(candidate)) await origRename(candidate, parked); + await origMkdir(candidate); + await origMkdir(custom); + await origMkdir(path.join(custom, '.cindy-library', 'tmp'), { recursive: true }); + await origWriteFile(path.join(custom, '.cindy-library', 'meta.json'), JSON.stringify({ + version: 1, ghostId: GHOST_ID, createdAt: 1, + })); + await origWriteFile(path.join(custom, 'user-keep.txt'), 'user'); + } + return origReaddir(target, options); + }); + createVault.mockImplementation((d) => new LibraryVault({ + ...d, + initCustomTree: async (req) => { + const r = await initCustomLibraryTree(req); + afterInit = true; + return r; + }, + })); + const opened = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + readdirSpy.mockRestore(); + if (!opened.ok || opened.op !== 'open') throw new Error(JSON.stringify(opened)); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'usage.json'))).toBe(false); + if (swapped) { + expect(fs.existsSync(path.join(custom, 'user-keep.txt'))).toBe(true); + expect(opened.authorizedReadonly).toBe(false); + } + }); + + it('旧 session teardown 只删自己捕获的引用,不踩并发新建的 session', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const first = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!first.ok || first.op !== 'open') throw new Error(JSON.stringify(first)); + expect(first.state).toBe('ready'); + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + let n = 0; + const origResolve = LibraryBindingStore.prototype.resolveLibraryRoot.bind(bindingStore); + resolveLibraryRoot.mockImplementation(async (id: string) => { + const i = ++n; + const res = await origResolve(id); + if (i >= 2) await gate; + return res; + }); + const other = path.join(tmp, 'picked-concurrent'); + await fs.promises.mkdir(other); + const pendingA = slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + await Promise.resolve(); + await bindingStore.setBinding(GHOST_ID, other); + const pendingB = slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + release(); + const [a, b] = await Promise.all([pendingA, pendingB]); + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + const later = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + expect(later.ok).toBe(true); + if (later.ok && later.op === 'status') { + expect(later.state === 'ready' || later.state === 'unavailable').toBe(true); + } + }); + + it('已挂 extraDir 时 confirm 与 vault.open 间 disk-missing 必须撤 grant', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + expect(open.authorizedReadonly).toBe(true); + const grantedRoot = syncAgentReadonlyExtraDir.mock.calls.find((call) => call[0] === GHOST_ID && call[1] !== null)?.[1]; + expect(typeof grantedRoot).toBe('string'); + const parked = `${candidate}.parked`; + const vault = createVault.mock.results.at(-1)?.value as LibraryVault; + const orig = vault.open.bind(vault); + vault.open = async () => { + if (fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + return orig(); + }; + const raced = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!raced.ok || raced.op !== 'open') throw new Error(JSON.stringify(raced)); + expect(raced.state).toBe('unavailable'); + expect(raced.reason).toBe('disk-missing'); + expect(raced.authorizedReadonly).toBe(false); + const nullGrants = syncAgentReadonlyExtraDir.mock.calls.filter((call) => call[0] === GHOST_ID && call[1] === null); + expect(nullGrants.length).toBeGreaterThan(0); + }); + + it('auto-open 失败后同盘归位只 status 须恢复', async () => { + const bound = await bindingStore.setBinding(GHOST_ID, candidate); + expect(bound.ok).toBe(true); + const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); + await slot.handleLibraryRequest(GHOST_ID, { op: 'write', path: 'keep.txt', content: 'keep-me' }); + const parked = `${candidate}.parked`; + let vanishOnOpen = true; + createVault.mockImplementation((deps) => { + const vault = new LibraryVault(deps); + const orig = vault.open.bind(vault); + vault.open = async () => { + if (vanishOnOpen && fs.existsSync(candidate)) await fs.promises.rename(candidate, parked); + return orig(); + }; + return vault; + }); + await slot.disposeAll(); + const statusMissing = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!statusMissing.ok || statusMissing.op !== 'status') throw new Error(JSON.stringify(statusMissing)); + expect(statusMissing.state).toBe('unavailable'); + expect(statusMissing.reason).toBe('disk-missing'); + vanishOnOpen = false; + await fs.promises.rename(parked, candidate); + const statusRestored = await slot.handleLibraryRequest(GHOST_ID, { op: 'status' }); + if (!statusRestored.ok || statusRestored.op !== 'status') throw new Error(JSON.stringify(statusRestored)); + expect(statusRestored.state).toBe('ready'); + const reread = await slot.handleLibraryRequest(GHOST_ID, { op: 'read', path: 'keep.txt' }); + if (!reread.ok || reread.op !== 'read') throw new Error(JSON.stringify(reread)); + expect(reread.content).toBe('keep-me'); + }); + it('重装自愈:meta 带 orphaned 标记时,会话建立自动清除', async () => { const root = path.join(defaultRootBase, GHOST_ID); await fs.promises.mkdir(path.join(root, '.cindy-library'), { recursive: true }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts index 5069a386c54..55ce4afe88a 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/libraryVault.test.ts @@ -4,7 +4,7 @@ * os.tmpdir 临时目录(规则 23:生成物不落仓库工作区),零 Electron。 * symlink 用例带能力探针(Windows 无特权时跳过;POSIX CI 实跑)。 */ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -19,6 +19,7 @@ import { type LibraryFileIdentity, type LibraryReadHandle, } from '../libraryVault.js'; +import { initCustomLibraryTree, openExistingCustomLibrary, parseExistingStdout, PROVABLE_STAGING_NAME } from '../libraryDirFd.js'; const sha256Of = (s: string): string => createHash('sha256').update(s).digest('hex'); @@ -123,6 +124,295 @@ describe('LibraryVault', () => { expect(stat.isDirectory()).toBe(true); }); + it('custom 用户父目录消失: open 报 disk-missing 且不重建空库; keep 仍在 rename 走的目录', async () => { + const parent = path.join(tmpRoot, 'picked'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const first = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + const opened = await first.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.rename(parent, `${parent}.parked`); + const second = makeVault({ rootDir: () => custom, locationKind: 'custom' }); + const missing = await second.open(); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(parent)).toBe(false); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(`${parent}.parked`, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + + it('custom 最后一次 inspect 后、骨架 mkdir 前父目录被移走:不得 recursive 重建空库', async () => { + const parent = path.join(tmpRoot, 'picked-411'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const parked = `${parent}.parked`; + let injected = false; + const raced = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + openExistingCustom: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + } + return openExistingCustomLibrary(req); + }, + }); + const missing = await raced.open(); + expect(injected).toBe(true); + expect(missing).toMatchObject({ ok: true, state: 'unavailable', reason: 'disk-missing' }); + expect(fs.existsSync(parent)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + + it('最后一次成功 inspect 后、mkdir(root) 前换成同路径新 inode:不得在替换目录创建/写 meta', async () => { + if (process.platform === 'win32') return; + const parent = path.join(tmpRoot, 'picked-379'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + await fs.promises.writeFile(path.join(custom, 'keep.txt'), 'keep-me'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ rootDir: () => custom, locationKind: 'custom', customParentGrant: grant }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const parked = `${parent}.parked`; + let injected = false; + const raced = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + openExistingCustom: async (req) => { + if (!injected) { + injected = true; + if (fs.existsSync(parent)) await fs.promises.rename(parent, parked); + await fs.promises.mkdir(parent); + } + return openExistingCustomLibrary(req); + }, + }); + const opened = await raced.open(); + expect(injected).toBe(true); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'binding-moved' }); + expect(fs.existsSync(custom)).toBe(false); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(false); + expect(fs.existsSync(path.join(parked, 'mivo-canvas', 'keep.txt'))).toBe(true); + }); + + it('D: initCustomLibraryTree 后 sweep readdir 换根不得写 replacement usage.json', async () => { + if (process.platform === 'win32') return; + const parent = path.join(tmpRoot, 'picked-D'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(parent); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const parked = `${parent}.parked`; + let afterInit = false; + let swapped = false; + const origReaddir = fs.promises.readdir.bind(fs.promises); + const origRename = fs.promises.rename.bind(fs.promises); + const origMkdir = fs.promises.mkdir.bind(fs.promises); + const origWriteFile = fs.promises.writeFile.bind(fs.promises); + const readdirSpy = vi.spyOn(fs.promises, 'readdir').mockImplementation(async (target, options) => { + const dest = String(target); + if (afterInit && !swapped && dest.includes(`${path.sep}mivo-canvas${path.sep}.cindy-library${path.sep}tmp`)) { + swapped = true; + if (fs.existsSync(parent)) await origRename(parent, parked); + await origMkdir(parent); + await origMkdir(custom); + await origMkdir(path.join(custom, '.cindy-library')); + await origMkdir(path.join(custom, '.cindy-library', 'tmp')); + await origWriteFile(path.join(custom, '.cindy-library', 'meta.json'), JSON.stringify({ + version: 1, ghostId: 'mivo-canvas', createdAt: 1, + })); + await origWriteFile(path.join(custom, 'user-keep.txt'), 'user'); + await origWriteFile(path.join(custom, '.cindy-library', 'tmp', 'old.tmp'), 'stale'); + } + return origReaddir(target, options); + }); + const vault = makeVault({ + rootDir: () => custom, + locationKind: 'custom', + customParentGrant: grant, + ghostId: 'mivo-canvas', + initCustomTree: async (req) => { + const r = await initCustomLibraryTree(req); + afterInit = true; + return r; + }, + }); + const opened = await vault.open(); + readdirSpy.mockRestore(); + expect(opened.ok).toBe(true); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'usage.json'))).toBe(false); + if (swapped) { + expect(fs.existsSync(path.join(custom, 'user-keep.txt'))).toBe(true); + expect(opened).toMatchObject({ state: 'unavailable' }); + } else { + expect(opened).toMatchObject({ state: 'ready' }); + expect(fs.existsSync(path.join(parent, 'mivo-canvas', '.cindy-library', 'usage.json'))).toBe(false); + } + }); + + it('已有 custom 再 open 走 existing,不调用 create helper', async () => { + const parent = path.join(tmpRoot, 'picked-exist'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const init = vi.fn(async () => ({ ok: false as const, code: 'UNSUPPORTED' as const })); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + initCustomTree: init, + }); + expect(await second.open()).toMatchObject({ ok: true, state: 'ready' }); + expect(init).not.toHaveBeenCalled(); + }); + + it('existing UNSUPPORTED 且无完整结构: permission 且不 mkdir', async () => { + const parent = path.join(tmpRoot, 'picked-win'); + await fs.promises.mkdir(parent); + const custom = path.join(parent, 'mivo-canvas'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const vault = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + openExistingCustom: async () => ({ ok: false as const, code: 'UNSUPPORTED' as const }), + initCustomTree: async () => ({ ok: false as const, code: 'UNSUPPORTED' as const }), + }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'unavailable', reason: 'permission' }); + expect(fs.existsSync(custom)).toBe(false); + }); + + it('合法 usage.json 只读复用,不因 custom open 丢账本', async () => { + const parent = path.join(tmpRoot, 'picked-ledger'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const w = await first.write({ path: 'keep.txt', content: 'abcdef' }); + expect(w.ok).toBe(true); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + const opened = await second.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready', usedBytes: Buffer.byteLength('abcdef') }); + }); + + it('existing-open payload: pretty 与 compact 合法 meta 都读, malformed 仍 CORRUPT', () => { + const compact = 'OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}\n{"files":2,"bytes":10,"updatedAt":1,"mutations":0}'; + const prettyMeta = JSON.stringify({ version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, null, 2); + const prettyUsage = JSON.stringify({ files: 2, bytes: 10, updatedAt: 1, mutations: 0 }, null, 2); + const pretty = `OK\n${prettyMeta}\n${prettyUsage}`; + expect(parseExistingStdout(compact)).toMatchObject({ + ok: true, meta: { version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, usage: { files: 2, bytes: 10 }, + }); + expect(parseExistingStdout(pretty)).toMatchObject({ + ok: true, meta: { version: 1, ghostId: 'mivo-canvas', createdAt: 1 }, usage: { files: 2, bytes: 10 }, + }); + expect(parseExistingStdout('OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}')).toMatchObject({ + ok: true, usage: null, + }); + expect(parseExistingStdout('OK\n{not json')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('OK\n{"version":2,"ghostId":"mivo-canvas","createdAt":1}')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('OK\n{"version":1,"ghostId":"mivo-canvas","createdAt":1}\n{nope')).toMatchObject({ ok: false, code: 'CORRUPT' }); + expect(parseExistingStdout('MISSING')).toMatchObject({ ok: false, code: 'MISSING' }); + }); + + it('pretty-printed 落盘 meta 再 open 仍 ready,不降校验', async () => { + const parent = path.join(tmpRoot, 'picked-pretty'); + const custom = path.join(parent, 'mivo-canvas'); + await fs.promises.mkdir(custom, { recursive: true }); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const first = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await first.open()).toMatchObject({ ok: true, state: 'ready' }); + const metaPath = path.join(custom, '.cindy-library', 'meta.json'); + const compact = JSON.parse(await fs.promises.readFile(metaPath, 'utf8')); + await fs.promises.writeFile(metaPath, JSON.stringify(compact, null, 2)); + const second = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await second.open()).toMatchObject({ ok: true, state: 'ready' }); + await fs.promises.writeFile(metaPath, '{not json'); + const third = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + expect(await third.open()).toMatchObject({ ok: true, state: 'unavailable', reason: 'corrupt' }); + }); + + it('可证 staging 名只匹配 uuid.tmp/stream,不匹配 old.tmp 或原件', () => { + expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.tmp')).toBe(true); + expect(PROVABLE_STAGING_NAME.test('25ae5922-06f7-46dd-99f1-6d914d53af33.stream')).toBe(true); + expect(PROVABLE_STAGING_NAME.test('old.tmp')).toBe(false); + expect(PROVABLE_STAGING_NAME.test('keep.txt')).toBe(false); + expect(PROVABLE_STAGING_NAME.test('meta.json')).toBe(false); + }); + + it('Windows 新建 custom 走稳定 parent handle 首建 ready', async () => { + if (process.platform !== 'win32') return; + const parent = path.join(tmpRoot, 'picked-win-new'); + await fs.promises.mkdir(parent); + const custom = path.join(parent, 'mivo-canvas'); + const parentStat = await fs.promises.lstat(parent); + const grant = { + realPathAtGrant: await fs.promises.realpath(parent), + identity: { dev: parentStat.dev, ino: parentStat.ino }, + }; + const vault = makeVault({ + rootDir: () => custom, locationKind: 'custom', customParentGrant: grant, ghostId: 'mivo-canvas', + }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + expect(fs.existsSync(path.join(custom, '.cindy-library', 'meta.json'))).toBe(true); + }); + + it('default 缺失根仍可首次创建', async () => { + const missing = path.join(tmpRoot, 'brand-new-default', 'ghost'); + const vault = makeVault({ rootDir: () => missing, locationKind: 'default' }); + const opened = await vault.open(); + expect(opened).toMatchObject({ ok: true, state: 'ready' }); + expect(fs.existsSync(path.join(missing, '.cindy-library', 'meta.json'))).toBe(true); + }); + it('meta 损坏 → unavailable(corrupt),绝不静默重建空库', async () => { await fs.promises.mkdir(path.join(libraryRoot, '.cindy-library'), { recursive: true }); await fs.promises.writeFile(path.join(libraryRoot, '.cindy-library', 'meta.json'), '{not json'); diff --git a/apps/desktop/src/main/cindy-brain/libraryDirFd.ts b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts new file mode 100644 index 00000000000..cd6691bb883 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/libraryDirFd.ts @@ -0,0 +1,1062 @@ +/** + * Custom Library first-create: mkdir/open/meta stay on a held parent directory + * fd. Darwin uses a fixed /usr/bin/perl mkdirat/openat helper (SYS_mkdirat=475, + * SYS_openat=463 from MacOSX.sdk sys/syscall.h). Linux uses /proc/self/fd. + * Windows uses the same PowerShell NtCreateFile helper: FILE_OPEN_IF dirs and + * FILE_CREATE meta from the inherited parent handle. Missing helpers fail + * closed before any mutation. No path mkdir fallback. Segments are + * fixed/validated names, never concatenated user paths. + */ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export type CustomTreeInitResult = + | { ok: true; createdMeta: boolean } + | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' }; + +export type CustomExistingMeta = { version: 1; ghostId: string; createdAt: number }; +export type CustomExistingUsage = { files: number; bytes: number; updatedAt: number; mutations: number }; +export type CustomExistingResult = + | { ok: true; meta: CustomExistingMeta; usage: CustomExistingUsage | null } + | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'INVALID' | 'MISSING' | 'CORRUPT' }; + +const HELPER_TIMEOUT_MS = 15_000; +const SYS_OPENAT = 463; +const SYS_MKDIRAT = 475; +const SYS_FSYNC = 95; + +function validSegment(segment: string): boolean { + return ( + segment.length > 0 && + segment.length <= 255 && + segment !== '.' && + segment !== '..' && + !segment.includes('\0') && + !segment.includes('/') && + !segment.includes('\\') + ); +} + +const DARWIN_INIT_SCRIPT = String.raw` +use strict; +use warnings; +use Fcntl qw(O_RDONLY O_WRONLY O_CREAT O_EXCL O_DIRECTORY O_NOFOLLOW :mode); +use POSIX qw(write close); +use Errno qw(EEXIST); + +use constant SYS_openat => ${SYS_OPENAT}; +use constant SYS_mkdirat => ${SYS_MKDIRAT}; +use constant SYS_fsync => ${SYS_FSYNC}; + +sub fail_closed { exit 1; } + +sub valid_segment { + my ($segment) = @_; + return 0 if !defined($segment) || $segment eq '' || length($segment) > 255; + return 0 if $segment eq '.' || $segment eq '..'; + return 0 if index($segment, '/') >= 0 || index($segment, "\\") >= 0 || index($segment, "\0") >= 0; + return 1; +} + +sub mkdirat_seg { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $mode = 0700; + my $rc = syscall(SYS_mkdirat, $parent + 0, $seg, $mode); + if (!defined($rc) || $rc < 0) { + fail_closed() unless $! == EEXIST; + } +} + +sub openat_dir { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW | O_DIRECTORY; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + fail_closed() if !defined($fd) || $fd < 0; + return $fd; +} + +my $ghost = $ARGV[0]; +fail_closed() unless valid_segment($ghost); +my $meta = $ENV{CINDY_LIBRARY_META_JSON} // ''; +fail_closed() unless $meta =~ /^\{"version":1,"ghostId":"[A-Za-z0-9._-]{1,128}","createdAt":[0-9]{1,16}\}$/; + +my $parent = fileno(STDIN); +fail_closed() unless defined $parent && $parent >= 0; +my @pst = stat(STDIN); +fail_closed() unless @pst && S_ISDIR($pst[2]); + +mkdirat_seg($parent, $ghost); +my $root = openat_dir($parent, $ghost); +mkdirat_seg($root, '.cindy-library'); +my $meta_dir = openat_dir($root, '.cindy-library'); +mkdirat_seg($meta_dir, 'tmp'); +mkdirat_seg($meta_dir, 'backups'); + +my $flags = O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW; +my $meta_name = 'meta.json'; +my $meta_mode = 0600; +my $mfd = syscall(SYS_openat, $meta_dir + 0, $meta_name, $flags, $meta_mode); +my $created = 0; +if (defined($mfd) && $mfd >= 0) { + $created = 1; + my $w = POSIX::write($mfd, $meta, length($meta)); + fail_closed() unless defined($w) && $w == length($meta); + syscall(SYS_fsync, $mfd); + POSIX::close($mfd); +} else { + fail_closed() unless $! == EEXIST; +} + +POSIX::close($meta_dir); +POSIX::close($root); +print STDOUT ($created ? 'created' : 'exists'); +`; + +function runDarwinInit(parentFd: number, ghostId: string, metaJson: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn('/usr/bin/perl', ['-e', DARWIN_INIT_SCRIPT, '--', ghostId], { + stdio: [parentFd, 'pipe', 'pipe'], + env: { CINDY_LIBRARY_META_JSON: metaJson }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomTreeInitResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code === 0 && (text === 'created' || text === 'exists')) { + finish({ ok: true, createdMeta: text === 'created' }); + return; + } + finish({ ok: false, code: 'IO' }); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} + +function linuxInit(parentFd: number, ghostId: string, metaJson: string): CustomTreeInitResult { + const opened: number[] = []; + try { + const mkdirAt = (dirFd: number, name: string): void => { + try { + fs.mkdirSync(`/proc/self/fd/${dirFd}/${name}`, { recursive: false }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + }; + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + const st = fs.fstatSync(fd); + if (!st.isDirectory()) throw Object.assign(new Error('not dir'), { code: 'ENOTDIR' }); + return fd; + }; + mkdirAt(parentFd, ghostId); + const rootFd = openDirAt(parentFd, ghostId); + mkdirAt(rootFd, '.cindy-library'); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + mkdirAt(metaDirFd, 'tmp'); + mkdirAt(metaDirFd, 'backups'); + let createdMeta = false; + try { + const flags = + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_NOFOLLOW ?? 0); + const metaFd = fs.openSync(`/proc/self/fd/${metaDirFd}/meta.json`, flags, 0o600); + opened.push(metaFd); + fs.writeSync(metaFd, metaJson); + fs.fsyncSync(metaFd); + createdMeta = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + return { ok: true, createdMeta }; + } catch { + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close helper fds */ + } + } + } +} + +export async function initCustomLibraryTree(req: { + parentFd: number; + ghostId: string; + metaJson: string; +}): Promise { + if (!validSegment(req.ghostId) || !Number.isInteger(req.parentFd) || req.parentFd < 0) { + return { ok: false, code: 'INVALID' }; + } + if (!/^\{"version":1,"ghostId":"[A-Za-z0-9._-]{1,128}","createdAt":[0-9]{1,16}\}$/.test(req.metaJson)) { + return { ok: false, code: 'INVALID' }; + } + if (process.platform === 'darwin') return runDarwinInit(req.parentFd, req.ghostId, req.metaJson); + if (process.platform === 'linux') return linuxInit(req.parentFd, req.ghostId, req.metaJson); + if (process.platform === 'win32') return runWindowsInit(req.parentFd, req.ghostId, req.metaJson); + return { ok: false, code: 'UNSUPPORTED' }; +} + +const DARWIN_OPEN_EXISTING_SCRIPT = String.raw` +use strict; +use warnings; +use Fcntl qw(O_RDONLY O_DIRECTORY O_NOFOLLOW :mode); +use POSIX qw(read close); + +use constant SYS_openat => ${SYS_OPENAT}; + +sub fail_closed { exit 1; } +sub missing { print STDOUT 'MISSING'; exit 0; } + +sub valid_segment { + my ($segment) = @_; + return 0 if !defined($segment) || $segment eq '' || length($segment) > 255; + return 0 if $segment eq '.' || $segment eq '..'; + return 0 if index($segment, '/') >= 0 || index($segment, "\\") >= 0 || index($segment, "\0") >= 0; + return 1; +} + +sub openat_dir { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW | O_DIRECTORY; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + missing() if !defined($fd) || $fd < 0; + return $fd; +} + +sub openat_file { + my ($parent, $name) = @_; + fail_closed() unless valid_segment($name); + my $seg = "$name"; + my $flags = O_RDONLY | O_NOFOLLOW; + my $fd = syscall(SYS_openat, $parent + 0, $seg, $flags, 0); + return undef if !defined($fd) || $fd < 0; + return $fd; +} + +sub read_all { + my ($fd) = @_; + my $buf = ''; + while (1) { + my $chunk = ''; + my $n = POSIX::read($fd, $chunk, 8192); + last if !defined($n) || $n == 0; + $buf .= $chunk; + } + return $buf; +} + +my $ghost = $ARGV[0]; +fail_closed() unless valid_segment($ghost); +my $parent = fileno(STDIN); +fail_closed() unless defined $parent && $parent >= 0; +my @pst = stat(STDIN); +fail_closed() unless @pst && S_ISDIR($pst[2]); + +my $root = openat_dir($parent, $ghost); +my $meta_dir = openat_dir($root, '.cindy-library'); +openat_dir($meta_dir, 'tmp'); +openat_dir($meta_dir, 'backups'); +my $mfd = openat_file($meta_dir, 'meta.json'); +missing() unless defined $mfd; +my $meta_raw = read_all($mfd); +POSIX::close($mfd); +my $ufd = openat_file($meta_dir, 'usage.json'); +my $usage_raw = ''; +if (defined $ufd) { + $usage_raw = read_all($ufd); + POSIX::close($ufd); +} +POSIX::close($meta_dir); +POSIX::close($root); +print STDOUT "OK\n$meta_raw\n"; +print STDOUT $usage_raw; +`; + +function extractJsonValue(source: string, from: number): { json: string; end: number } | null { + let i = from; + while (i < source.length && (source[i] === ' ' || source[i] === '\n' || source[i] === '\r' || source[i] === '\t')) i += 1; + if (i >= source.length || (source[i] !== '{' && source[i] !== '[')) return null; + const start = i; + let depth = 0; + let inStr = false; + let esc = false; + for (; i < source.length; i += 1) { + const c = source[i]; + if (inStr) { + if (esc) { + esc = false; + continue; + } + if (c === '\\') { + esc = true; + continue; + } + if (c === '"') inStr = false; + continue; + } + if (c === '"') { + inStr = true; + continue; + } + if (c === '{' || c === '[') depth += 1; + if (c === '}' || c === ']') { + depth -= 1; + if (depth === 0) return { json: source.slice(start, i + 1), end: i + 1 }; + } + } + return null; +} + +/** Existing-open helper payload: `OK` + complete JSON value(s), or `MISSING`. Pretty or compact. */ +export function parseExistingStdout(text: string): CustomExistingResult { + const body = text.replace(/^\uFEFF/, ''); + const trimmed = body.replace(/^\s+/, ''); + if (trimmed === 'MISSING' || trimmed.startsWith('MISSING\n') || trimmed.startsWith('MISSING\r\n')) { + return { ok: false, code: 'MISSING' }; + } + if (!trimmed.startsWith('OK')) return { ok: false, code: 'IO' }; + let rest = trimmed.slice(2); + if (rest.startsWith('\r\n')) rest = rest.slice(2); + else if (rest.startsWith('\n')) rest = rest.slice(1); + const metaTok = extractJsonValue(rest, 0); + if (!metaTok) return { ok: false, code: 'CORRUPT' }; + let meta: CustomExistingMeta; + try { + const parsed = JSON.parse(metaTok.json) as CustomExistingMeta; + if ( + typeof parsed !== 'object' || parsed === null || parsed.version !== 1 || + typeof parsed.ghostId !== 'string' || typeof parsed.createdAt !== 'number' + ) { + return { ok: false, code: 'CORRUPT' }; + } + meta = parsed; + } catch { + return { ok: false, code: 'CORRUPT' }; + } + const afterMeta = rest.slice(metaTok.end); + const usageTok = extractJsonValue(afterMeta, 0); + let usage: CustomExistingUsage | null = null; + if (usageTok) { + try { + const parsed = JSON.parse(usageTok.json) as CustomExistingUsage; + if ( + typeof parsed !== 'object' || parsed === null || + typeof parsed.files !== 'number' || typeof parsed.bytes !== 'number' + ) { + return { ok: false, code: 'CORRUPT' }; + } + usage = { + files: parsed.files, + bytes: parsed.bytes, + updatedAt: parsed.updatedAt ?? 0, + mutations: parsed.mutations ?? 0, + }; + } catch { + return { ok: false, code: 'CORRUPT' }; + } + if (afterMeta.slice(usageTok.end).trim() !== '') return { ok: false, code: 'CORRUPT' }; + } else if (afterMeta.trim() !== '') { + return { ok: false, code: 'CORRUPT' }; + } + return { ok: true, meta, usage }; +} + +function runDarwinOpenExisting(parentFd: number, ghostId: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn('/usr/bin/perl', ['-e', DARWIN_OPEN_EXISTING_SCRIPT, '--', ghostId], { + stdio: [parentFd, 'pipe', 'pipe'], + env: { PATH: '/usr/bin:/bin' }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomExistingResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code !== 0) { + finish({ ok: false, code: text === 'MISSING' ? 'MISSING' : 'IO' }); + return; + } + finish(parseExistingStdout(text)); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} + +function linuxOpenExisting(parentFd: number, ghostId: string): CustomExistingResult { + const opened: number[] = []; + try { + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + if (!fs.fstatSync(fd).isDirectory()) throw Object.assign(new Error('not dir'), { code: 'ENOTDIR' }); + return fd; + }; + const rootFd = openDirAt(parentFd, ghostId); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + openDirAt(metaDirFd, 'tmp'); + openDirAt(metaDirFd, 'backups'); + const metaFd = fs.openSync(`/proc/self/fd/${metaDirFd}/meta.json`, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + opened.push(metaFd); + const metaRaw = fs.readFileSync(metaFd, 'utf8'); + let usageRaw = ''; + try { + const usageFd = fs.openSync(`/proc/self/fd/${metaDirFd}/usage.json`, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + opened.push(usageFd); + usageRaw = fs.readFileSync(usageFd, 'utf8'); + } catch { + usageRaw = ''; + } + return parseExistingStdout(`OK\n${metaRaw}\n${usageRaw}`); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close */ + } + } + } +} + +/** Read an already-initialized custom library from the held parent fd. Never mkdir. */ +export async function openExistingCustomLibrary(req: { + parentFd: number; + ghostId: string; +}): Promise { + if (!validSegment(req.ghostId) || !Number.isInteger(req.parentFd) || req.parentFd < 0) { + return { ok: false, code: 'INVALID' }; + } + if (process.platform === 'darwin') return runDarwinOpenExisting(req.parentFd, req.ghostId); + if (process.platform === 'linux') return linuxOpenExisting(req.parentFd, req.ghostId); + if (process.platform === 'win32') return runWindowsOpenExisting(req.parentFd, req.ghostId); + return { ok: false, code: 'UNSUPPORTED' }; +} + +function windowsPowerShellPath(): string | null { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (!systemRoot || !path.win32.isAbsolute(systemRoot)) return null; + const executable = path.win32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + try { + return fs.statSync(executable).isFile() ? executable : null; + } catch { + return null; + } +} + +/** Staging leftovers from atomicWrite (`uuid.tmp`) and streams (`uuid.stream`). Not unique originals. */ +export const PROVABLE_STAGING_NAME = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(tmp|stream)$/i; + +/** + * Age + empty streams Map cannot prove uuid.tmp/.stream are garbage: + * atomicWrite/stream may have fsynced a complete unique payload before rename. + * Diagnostic list only. No unlink. P2_tmp UNRESOLVED. + */ +export function listProvableStagingOnLinux( + parentFd: number, + ghostId: string, +): { ok: true; names: string[] } | { ok: false; code: 'UNSUPPORTED' | 'IO' | 'MISSING' } { + if (process.platform !== 'linux') return { ok: false, code: 'UNSUPPORTED' }; + if (!validSegment(ghostId) || !Number.isInteger(parentFd) || parentFd < 0) { + return { ok: false, code: 'IO' }; + } + const opened: number[] = []; + try { + const openDirAt = (dirFd: number, name: string): number => { + let flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + if (fs.constants.O_DIRECTORY) flags |= fs.constants.O_DIRECTORY; + const fd = fs.openSync(`/proc/self/fd/${dirFd}/${name}`, flags); + opened.push(fd); + return fd; + }; + const rootFd = openDirAt(parentFd, ghostId); + const metaDirFd = openDirAt(rootFd, '.cindy-library'); + const tmpFd = openDirAt(metaDirFd, 'tmp'); + const names = fs.readdirSync(`/proc/self/fd/${tmpFd}`).filter((name) => PROVABLE_STAGING_NAME.test(name)); + return { ok: true, names }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') return { ok: false, code: 'MISSING' }; + return { ok: false, code: 'IO' }; + } finally { + for (const fd of opened.reverse()) { + try { + fs.closeSync(fd); + } catch { + /* always close */ + } + } + } +} + +const WINDOWS_EXISTING_OPEN_SCRIPT = String.raw` +$utf8 = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = $utf8 +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class CindyLibraryExistingOpen { + private const uint FILE_READ_DATA = 0x00000001; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint SYNCHRONIZE = 0x00100000; + private const uint FILE_SHARE_ALL = 0x00000007; + private const uint FILE_SHARE_READ_WRITE = 0x00000003; + private const uint FILE_OPEN = 0x00000001; + private const uint FILE_DIRECTORY_FILE = 0x00000001; + private const uint FILE_NON_DIRECTORY_FILE = 0x00000040; + private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; + private const uint FILE_OPEN_REPARSE_POINT = 0x00200000; + private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + [StructLayout(LayoutKind.Sequential)] + private struct OBJECT_ATTRIBUTES { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + [StructLayout(LayoutKind.Sequential)] + private struct IO_STATUS_BLOCK { + public IntPtr Status; + public IntPtr Information; + } + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ATTRIBUTE_TAG_INFO { + public uint FileAttributes; + public uint ReparseTag; + } + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle, out BY_HANDLE_FILE_INFORMATION info); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandleEx(IntPtr handle, int infoClass, out FILE_ATTRIBUTE_TAG_INFO info, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool ReadFile(IntPtr hFile, byte[] buffer, uint toRead, out uint read, IntPtr overlapped); + [DllImport("ntdll.dll")] static extern int NtCreateFile(out SafeFileHandle fileHandle, uint desiredAccess, ref OBJECT_ATTRIBUTES objectAttributes, out IO_STATUS_BLOCK ioStatusBlock, IntPtr allocationSize, uint fileAttributes, uint shareAccess, uint createDisposition, uint createOptions, IntPtr eaBuffer, uint eaLength); + + private static bool ValidSegment(string segment) { + if (String.IsNullOrEmpty(segment) || segment.Length > 255) return false; + if (segment == "." || segment == "..") return false; + return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; + } + + private static SafeFileHandle OpenRelative(IntPtr root, string name, bool directory, bool readData) { + if (!ValidSegment(name)) return null; + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodePointer = IntPtr.Zero; + try { + nameBuffer = Marshal.StringToHGlobalUni(name); + var unicode = new UNICODE_STRING { + Length = checked((ushort)(name.Length * 2)), + MaximumLength = checked((ushort)((name.Length + 1) * 2)), + Buffer = nameBuffer + }; + unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING))); + Marshal.StructureToPtr(unicode, unicodePointer, false); + var attributes = new OBJECT_ATTRIBUTES { + Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)), + RootDirectory = root, + ObjectName = unicodePointer, + Attributes = 0, + SecurityDescriptor = IntPtr.Zero, + SecurityQualityOfService = IntPtr.Zero + }; + IO_STATUS_BLOCK statusBlock; + SafeFileHandle opened; + uint access = (readData ? FILE_READ_DATA : 0) | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); + int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, 0, + directory ? FILE_SHARE_READ_WRITE : FILE_SHARE_ALL, FILE_OPEN, options, IntPtr.Zero, 0); + if (status < 0 || opened == null || opened.IsInvalid) { + if (opened != null) opened.Dispose(); + return null; + } + FILE_ATTRIBUTE_TAG_INFO tag; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) || + (tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); + return null; + } + return opened; + } catch { + return null; + } finally { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + private static string ReadUtf8(IntPtr handle) { + var chunks = new List(); + byte[] buffer = new byte[4096]; + uint read; + while (ReadFile(handle, buffer, (uint)buffer.Length, out read, IntPtr.Zero) && read > 0) { + for (int i = 0; i < read; i++) chunks.Add(buffer[i]); + if (chunks.Count > 1048576) return null; + } + return Encoding.UTF8.GetString(chunks.ToArray()); + } + + public static int Run(string ghostId) { + if (!ValidSegment(ghostId)) return 2; + IntPtr parent = GetStdHandle(-10); + if (parent == IntPtr.Zero || parent == new IntPtr(-1)) return 2; + BY_HANDLE_FILE_INFORMATION parentInfo; + if (!GetFileInformationByHandle(parent, out parentInfo) || + (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; + var opened = new List(); + try { + SafeFileHandle ghost = OpenRelative(parent, ghostId, true, false); + if (ghost == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(ghost); + SafeFileHandle metaDir = OpenRelative(ghost.DangerousGetHandle(), ".cindy-library", true, false); + if (metaDir == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(metaDir); + SafeFileHandle tmp = OpenRelative(metaDir.DangerousGetHandle(), "tmp", true, false); + if (tmp == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(tmp); + SafeFileHandle backups = OpenRelative(metaDir.DangerousGetHandle(), "backups", true, false); + if (backups == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(backups); + SafeFileHandle meta = OpenRelative(metaDir.DangerousGetHandle(), "meta.json", false, true); + if (meta == null) { Console.Out.Write("MISSING"); return 0; } + opened.Add(meta); + string metaRaw = ReadUtf8(meta.DangerousGetHandle()); + if (metaRaw == null) return 1; + string usageRaw = ""; + SafeFileHandle usage = OpenRelative(metaDir.DangerousGetHandle(), "usage.json", false, true); + if (usage != null) { + opened.Add(usage); + usageRaw = ReadUtf8(usage.DangerousGetHandle()) ?? ""; + } + Console.Out.Write("OK\n" + metaRaw + "\n" + usageRaw); + return 0; + } finally { + for (int i = opened.Count - 1; i >= 0; i--) opened[i].Dispose(); + } + } +} +'@ +try { + $ghost = $env:CINDY_LIBRARY_GHOST_ID + $code = [CindyLibraryExistingOpen]::Run([string]$ghost) + exit $code +} catch { + exit 1 +} +`; + +const WINDOWS_EXISTING_OPEN_COMMAND = Buffer.from(WINDOWS_EXISTING_OPEN_SCRIPT, 'utf16le').toString('base64'); + +function runWindowsOpenExisting(parentFd: number, ghostId: string): Promise { + return new Promise((resolve) => { + const powershell = windowsPowerShellPath(); + if (!powershell) { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let child: ReturnType; + try { + child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_EXISTING_OPEN_COMMAND], { + stdio: [parentFd, 'pipe', 'pipe'], + windowsHide: true, + env: { ...process.env, CINDY_LIBRARY_GHOST_ID: ghostId }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomExistingResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8'); + if (code === 2) { + finish({ ok: false, code: 'UNSUPPORTED' }); + return; + } + if (code !== 0) { + finish({ ok: false, code: text === 'MISSING' ? 'MISSING' : 'IO' }); + return; + } + finish(parseExistingStdout(text)); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} + +const WINDOWS_INIT_SCRIPT = String.raw` +$utf8 = [System.Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = $utf8 +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Win32.SafeHandles; + +public static class CindyLibraryInit { + private const uint FILE_LIST_DIRECTORY = 0x00000001; + private const uint FILE_ADD_FILE = 0x00000002; + private const uint FILE_ADD_SUBDIRECTORY = 0x00000004; + private const uint FILE_TRAVERSE = 0x00000020; + private const uint FILE_WRITE_DATA = 0x00000002; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint SYNCHRONIZE = 0x00100000; + private const uint FILE_SHARE_READ_WRITE = 0x00000003; + private const uint FILE_SHARE_ALL = 0x00000007; + private const uint FILE_OPEN = 0x00000001; + private const uint FILE_CREATE = 0x00000002; + private const uint FILE_OPEN_IF = 0x00000003; + private const uint FILE_DIRECTORY_FILE = 0x00000001; + private const uint FILE_NON_DIRECTORY_FILE = 0x00000040; + private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; + private const uint FILE_OPEN_REPARSE_POINT = 0x00200000; + private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010; + private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080; + private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + private const int STATUS_OBJECT_NAME_COLLISION = unchecked((int)0xC0000035); + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + [StructLayout(LayoutKind.Sequential)] + private struct OBJECT_ATTRIBUTES { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + [StructLayout(LayoutKind.Sequential)] + private struct IO_STATUS_BLOCK { + public IntPtr Status; + public IntPtr Information; + } + [StructLayout(LayoutKind.Sequential)] + private struct FILE_ATTRIBUTE_TAG_INFO { + public uint FileAttributes; + public uint ReparseTag; + } + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle, out BY_HANDLE_FILE_INFORMATION info); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandleEx(IntPtr handle, int infoClass, out FILE_ATTRIBUTE_TAG_INFO info, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool WriteFile(IntPtr hFile, byte[] buffer, uint toWrite, out uint written, IntPtr overlapped); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool FlushFileBuffers(IntPtr hFile); + [DllImport("ntdll.dll")] static extern int NtCreateFile(out SafeFileHandle fileHandle, uint desiredAccess, ref OBJECT_ATTRIBUTES objectAttributes, out IO_STATUS_BLOCK ioStatusBlock, IntPtr allocationSize, uint fileAttributes, uint shareAccess, uint createDisposition, uint createOptions, IntPtr eaBuffer, uint eaLength); + + private static bool ValidSegment(string segment) { + if (String.IsNullOrEmpty(segment) || segment.Length > 255) return false; + if (segment == "." || segment == "..") return false; + return segment.IndexOf('/') < 0 && segment.IndexOf((char)92) < 0 && segment.IndexOf((char)0) < 0 && segment.IndexOf(':') < 0; + } + + private enum RelativeStatus { Ok, Collision, Reparse, Failed } + private struct RelativeOpen { + public SafeFileHandle Handle; + public RelativeStatus Status; + } + + private static RelativeOpen CreateRelative(IntPtr root, string name, bool directory, uint disposition, uint access) { + var failed = new RelativeOpen { Handle = null, Status = RelativeStatus.Failed }; + if (!ValidSegment(name)) return failed; + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodePointer = IntPtr.Zero; + SafeFileHandle opened = null; + try { + nameBuffer = Marshal.StringToHGlobalUni(name); + var unicode = new UNICODE_STRING { + Length = checked((ushort)(name.Length * 2)), + MaximumLength = checked((ushort)((name.Length + 1) * 2)), + Buffer = nameBuffer + }; + unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING))); + Marshal.StructureToPtr(unicode, unicodePointer, false); + var attributes = new OBJECT_ATTRIBUTES { + Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)), + RootDirectory = root, + ObjectName = unicodePointer, + Attributes = 0, + SecurityDescriptor = IntPtr.Zero, + SecurityQualityOfService = IntPtr.Zero + }; + IO_STATUS_BLOCK statusBlock; + uint options = FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT | + (directory ? FILE_DIRECTORY_FILE : FILE_NON_DIRECTORY_FILE); + uint fileAttributes = directory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL; + uint share = directory ? FILE_SHARE_READ_WRITE : FILE_SHARE_ALL; + int status = NtCreateFile(out opened, access, ref attributes, out statusBlock, IntPtr.Zero, fileAttributes, + share, disposition, options, IntPtr.Zero, 0); + if (status == STATUS_OBJECT_NAME_COLLISION) { + if (opened != null) { opened.Dispose(); opened = null; } + return new RelativeOpen { Handle = null, Status = RelativeStatus.Collision }; + } + if (status < 0 || opened == null || opened.IsInvalid) { + if (opened != null) { opened.Dispose(); opened = null; } + return failed; + } + FILE_ATTRIBUTE_TAG_INFO tag; + if (!GetFileInformationByHandleEx(opened.DangerousGetHandle(), 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO)))) { + opened.Dispose(); opened = null; + return failed; + } + if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + opened.Dispose(); opened = null; + return new RelativeOpen { Handle = null, Status = RelativeStatus.Reparse }; + } + if (directory && (tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + opened.Dispose(); opened = null; + return failed; + } + if (!directory && (tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + opened.Dispose(); opened = null; + return failed; + } + var ok = new RelativeOpen { Handle = opened, Status = RelativeStatus.Ok }; + opened = null; + return ok; + } catch { + if (opened != null) opened.Dispose(); + return failed; + } finally { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + public static int Run(string ghostId, string metaJson) { + if (!ValidSegment(ghostId)) return 2; + if (metaJson == null || !Regex.IsMatch(metaJson, "^\\{\"version\":1,\"ghostId\":\"[A-Za-z0-9._-]{1,128}\",\"createdAt\":[0-9]{1,16}\\}$")) return 2; + IntPtr parent = GetStdHandle(-10); + if (parent == IntPtr.Zero || parent == new IntPtr(-1)) return 2; + BY_HANDLE_FILE_INFORMATION parentInfo; + if (!GetFileInformationByHandle(parent, out parentInfo) || + (parentInfo.FileIndexHigh == 0 && parentInfo.FileIndexLow == 0)) return 2; + uint dirAccess = FILE_LIST_DIRECTORY | FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + RelativeOpen ghost = CreateRelative(parent, ghostId, true, FILE_OPEN_IF, dirAccess); + if (ghost.Status != RelativeStatus.Ok || ghost.Handle == null) return 1; + try { + RelativeOpen metaDir = CreateRelative(ghost.Handle.DangerousGetHandle(), ".cindy-library", true, FILE_OPEN_IF, dirAccess); + if (metaDir.Status != RelativeStatus.Ok || metaDir.Handle == null) return 1; + try { + RelativeOpen tmp = CreateRelative(metaDir.Handle.DangerousGetHandle(), "tmp", true, FILE_OPEN_IF, dirAccess); + if (tmp.Status != RelativeStatus.Ok || tmp.Handle == null) return 1; + tmp.Handle.Dispose(); + RelativeOpen backups = CreateRelative(metaDir.Handle.DangerousGetHandle(), "backups", true, FILE_OPEN_IF, dirAccess); + if (backups.Status != RelativeStatus.Ok || backups.Handle == null) return 1; + backups.Handle.Dispose(); + uint fileAccess = FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE; + RelativeOpen meta = CreateRelative(metaDir.Handle.DangerousGetHandle(), "meta.json", false, FILE_CREATE, fileAccess); + if (meta.Status == RelativeStatus.Ok && meta.Handle != null) { + try { + byte[] bytes = Encoding.UTF8.GetBytes(metaJson); + uint written; + if (!WriteFile(meta.Handle.DangerousGetHandle(), bytes, (uint)bytes.Length, out written, IntPtr.Zero) || written != (uint)bytes.Length) return 1; + if (!FlushFileBuffers(meta.Handle.DangerousGetHandle())) return 1; + Console.Out.Write("created"); + return 0; + } finally { + meta.Handle.Dispose(); + } + } + if (meta.Handle != null) meta.Handle.Dispose(); + if (meta.Status != RelativeStatus.Collision) return 1; + RelativeOpen existing = CreateRelative(metaDir.Handle.DangerousGetHandle(), "meta.json", false, FILE_OPEN, FILE_READ_ATTRIBUTES | SYNCHRONIZE); + if (existing.Status != RelativeStatus.Ok || existing.Handle == null) return 1; + existing.Handle.Dispose(); + Console.Out.Write("exists"); + return 0; + } finally { + metaDir.Handle.Dispose(); + } + } finally { + ghost.Handle.Dispose(); + } + } +} +'@ +try { + $ghost = $env:CINDY_LIBRARY_GHOST_ID + $meta = $env:CINDY_LIBRARY_META_JSON + $code = [CindyLibraryInit]::Run([string]$ghost, [string]$meta) + exit $code +} catch { + exit 1 +} +`; + +const WINDOWS_INIT_COMMAND = Buffer.from(WINDOWS_INIT_SCRIPT, 'utf16le').toString('base64'); + +function runWindowsInit(parentFd: number, ghostId: string, metaJson: string): Promise { + return new Promise((resolve) => { + const powershell = windowsPowerShellPath(); + if (!powershell) { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let child: ReturnType; + try { + child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_INIT_COMMAND], { + stdio: [parentFd, 'pipe', 'pipe'], + windowsHide: true, + env: { + ...process.env, + CINDY_LIBRARY_GHOST_ID: ghostId, + CINDY_LIBRARY_META_JSON: metaJson, + }, + }); + } catch { + resolve({ ok: false, code: 'UNSUPPORTED' }); + return; + } + let settled = false; + const finish = (value: CustomTreeInitResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const chunks: Buffer[] = []; + child.stdout?.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once('error', () => finish({ ok: false, code: 'UNSUPPORTED' })); + child.once('close', (code) => { + const text = Buffer.concat(chunks).toString('utf8').trim(); + if (code === 2) { + finish({ ok: false, code: 'UNSUPPORTED' }); + return; + } + if (code !== 0 || (text !== 'created' && text !== 'exists')) { + finish({ ok: false, code: 'IO' }); + return; + } + finish({ ok: true, createdMeta: text === 'created' }); + }); + const timer = setTimeout(() => { + child.kill(); + finish({ ok: false, code: 'IO' }); + }, HELPER_TIMEOUT_MS); + timer.unref?.(); + }); +} diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 3fc4e4db3ef..42fe6360978 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -310,21 +310,44 @@ export class GhostLibrarySlot { private async getOrCreateSession(ghostId: string, scopeKey: string | null): Promise { let session = this.sessions.get(ghostId); + const capturedScope = session; if (session && session.ownerScopeKey !== scopeKey) { - await this.teardownSession(ghostId); - session = undefined; + await this.teardownSession(ghostId, capturedScope); + session = this.sessions.get(ghostId); + if (session === capturedScope) session = undefined; + } + const resolution = await this.confirmLiveCustomRoot( + await this.deps.bindingStore.resolveLibraryRoot(ghostId), + ); + session = this.sessions.get(ghostId) ?? session; + if (session && session.ownerScopeKey !== scopeKey) { + const staleScope = session; + await this.teardownSession(ghostId, staleScope); + session = this.sessions.get(ghostId); + if (session === staleScope) session = undefined; + } + if (session && !this.sessionMatchesResolution(session, resolution)) { + const staleRoot = session; + await this.teardownSession(ghostId, staleRoot); + session = this.sessions.get(ghostId); + if (session === staleRoot) session = undefined; } if (!session) { - const resolution = await this.deps.bindingStore.resolveLibraryRoot(ghostId); session = this.createSession(ghostId, resolution, scopeKey); this.sessions.set(ghostId, session); // 会话建立即自动 open vault(幂等):消除"write 前忘 open"的脚枪。 // extraDirs 只在显式 open 时挂,status / 首次任意请求不得抢槽。 if (session.drift === null) { - await session.vault.open(); - // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned - // 标记(best-effort,失败不影响使用)。 - if (session.vault.getMeta()?.orphaned) { + const opened = await session.vault.open(); + if ( + opened.ok + && opened.state === 'unavailable' + && (opened.reason === 'disk-missing' || opened.reason === 'binding-moved') + ) { + await this.latchCustomUnavailable(session, ghostId, opened.reason); + } else if (session.vault.getMeta()?.orphaned) { + // 重装自愈:能走到这里 = 插件已装入且启用,清掉卸载时留的 orphaned + // 标记(best-effort,失败不影响使用)。 await session.vault.clearOrphaned().catch(() => {}); } } else if (this.extraDirGrant?.ghostId === ghostId) { @@ -363,6 +386,62 @@ export class GhostLibrarySlot { } } + /** Stale custom resolution after the user parent vanished or was replaced must not open/mkdir. */ + private async confirmLiveCustomRoot( + resolution: LibraryLocationResolution, + ): Promise { + if (resolution.kind !== 'custom' || resolution.root === null) return resolution; + const parent = path.dirname(resolution.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + let real: string; + try { + real = await fs.promises.realpath(parent); + } catch { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + if (real !== resolution.record.realPathAtGrant) { + return { kind: 'custom', root: null, drift: 'binding-moved', record: resolution.record }; + } + const identity = resolution.record.identity; + if (identity && identity.ino !== 0 && (st.dev !== identity.dev || st.ino !== identity.ino)) { + return { kind: 'custom', root: null, drift: 'binding-moved', record: resolution.record }; + } + } catch { + return { kind: 'custom', root: null, drift: 'disk-missing', record: resolution.record }; + } + return resolution; + } + + private async latchCustomUnavailable( + session: GhostLibrarySession, + ghostId: string, + reason: 'disk-missing' | 'binding-moved', + ): Promise { + session.drift = reason; + if (this.extraDirOpenerGhostId === ghostId) this.extraDirOpenerGhostId = null; + await this.syncAgentReadonlyExtraDir(ghostId, null); + } + + /** Cached sessions must re-check the live binding; a missing custom root is unavailable, not an empty mkdir. */ + private sessionMatchesResolution( + session: GhostLibrarySession, + resolution: LibraryLocationResolution, + ): boolean { + const drift = 'drift' in resolution && resolution.root === null ? resolution.drift : null; + if (session.drift !== drift || session.locationKind !== resolution.kind) return false; + const record = 'record' in resolution ? resolution.record : undefined; + if (session.generation !== (record?.generation ?? 0)) return false; + if (drift !== null) return true; + const root = resolution.kind === 'custom' && resolution.root !== null + ? resolution.root + : this.deps.getDefaultRoot(session.ghostId); + return session.vault.getRootDir() === root; + } + private createSession( ghostId: string, resolution: LibraryLocationResolution, @@ -378,6 +457,9 @@ export class GhostLibrarySlot { ghostId, getDiskFreeBytes: this.deps.getDiskFreeBytes, locationKind: resolution.kind, + customParentGrant: resolution.kind === 'custom' && resolution.root !== null + ? { realPathAtGrant: resolution.record.realPathAtGrant, identity: resolution.record.identity } + : undefined, log: this.deps.log, }); const sql = this.deps.createSqlService({ @@ -481,9 +563,10 @@ export class GhostLibrarySlot { } } - private async teardownSession(ghostId: string): Promise { + private async teardownSession(ghostId: string, expected?: GhostLibrarySession): Promise { const session = this.sessions.get(ghostId); if (!session) return; + if (expected && session !== expected) return; this.sessions.delete(ghostId); for (const [streamId, epoch] of this.writeEpochByStream) { if (epoch.ghostId === ghostId) this.writeEpochByStream.delete(streamId); @@ -592,6 +675,30 @@ export class GhostLibrarySlot { case 'open': { const r = await vault.open(); if (!r.ok) return vaultFail(r); + if (r.state === 'unavailable' && (r.reason === 'disk-missing' || r.reason === 'binding-moved')) { + await this.latchCustomUnavailable(session, ghostId, r.reason); + const drifted = { + ok: true as const, op: 'open' as const, state: 'unavailable' as const, + reason: r.reason, usedBytes: 0, fileCount: 0, location: session.locationKind, + }; + return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; + } + if (session.locationKind === 'custom') { + const live = await this.confirmLiveCustomRoot( + await this.deps.bindingStore.resolveLibraryRoot(ghostId), + ); + if (live.kind !== 'custom' || live.root === null) { + const reason = live.kind === 'custom' && live.root === null && live.drift === 'binding-moved' + ? 'binding-moved' + : 'disk-missing'; + await this.latchCustomUnavailable(session, ghostId, reason); + const drifted = { + ok: true as const, op: 'open' as const, state: 'unavailable' as const, + reason, usedBytes: 0, fileCount: 0, location: session.locationKind, + }; + return { ...drifted, ...this.handshakeFields(session, 'unavailable') } as GhostPipeLibraryResult; + } + } this.extraDirOpenerGhostId = ghostId; await this.syncAgentReadonlyExtraDir(ghostId, vault.getRootDir()); const body = { diff --git a/apps/desktop/src/main/cindy-brain/libraryVault.ts b/apps/desktop/src/main/cindy-brain/libraryVault.ts index 7856fdb1f18..03d856425ac 100644 --- a/apps/desktop/src/main/cindy-brain/libraryVault.ts +++ b/apps/desktop/src/main/cindy-brain/libraryVault.ts @@ -27,6 +27,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { isSafeGhostRelativePath } from '../../shared/ghost.js'; +import { + initCustomLibraryTree, + openExistingCustomLibrary, + type CustomTreeInitResult, +} from './libraryDirFd.js'; /** Library 操作的结构化错误码(fs 槽只有人话 message 的缺口在这里补上)。 */ export type LibraryErrorCode = @@ -136,8 +141,13 @@ export interface LibraryVaultDeps { * 兜底——比假装知道更诚实。 */ getDiskFreeBytes?(root: string): Promise; - /** 位置类别(仅透传给 status;binding 层提供,默认系统管理位置)。 */ + /** 位置类别。custom 根的用户父目录消失时 open 必须 fail-closed,不得 recursive mkdir 空库。 */ locationKind?: 'default' | 'custom'; + /** Custom parent identity from binding; compared at mkdir so a replaced inode cannot mint an empty library. */ + customParentGrant?: { + realPathAtGrant: string; + identity: { dev: number; ino: number } | null; + }; log?: { info: (msg: string, meta?: Record) => void; warn: (msg: string, meta?: Record) => void; @@ -152,6 +162,16 @@ export interface LibraryVaultDeps { * 读路径打开注入点,仅单测。生产缺省走 O_RDONLY|O_NOFOLLOW,失败不得回落裸 open。 */ openForRead?(absPath: string, flags: number): Promise; + /** Custom first-create via held parent fd. Tests may inject; production uses libraryDirFd. */ + initCustomTree?(req: { + parentFd: number; + ghostId: string; + metaJson: string; + }): Promise; + openExistingCustom?(req: { + parentFd: number; + ghostId: string; + }): Promise; } /** Windows 保留设备名(与 fsSlot/dirDeposit 同口径;目录名撞上同样出事)。 */ @@ -341,6 +361,64 @@ export class LibraryVault { return fsFailure(err, 'INTERNAL', message); } + /** Custom roots must not recreate a vanished or replaced user-selected parent. keep files stay in the renamed-away directory. */ + private customRootUnavailable( + reason: 'disk-missing' | 'binding-moved' = 'disk-missing', + ): LibrarySuccess<{ state: LibraryState; reason: string | null; usedBytes: number; fileCount: number }> { + this.state = 'unavailable'; + this.unavailableReason = reason; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } + + private async inspectCustomParent(): Promise | null> { + const parent = path.dirname(this.root); + const grant = this.deps.customParentGrant; + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable('disk-missing'); + let real: string; + try { + real = await fs.promises.realpath(parent); + } catch { + return this.customRootUnavailable('disk-missing'); + } + if (grant && real !== grant.realPathAtGrant) return this.customRootUnavailable('binding-moved'); + const stAfter = await fs.promises.lstat(parent); + if (stAfter.isSymbolicLink() || !stAfter.isDirectory()) return this.customRootUnavailable('disk-missing'); + if ( + grant?.identity + && grant.identity.ino !== 0 + && (stAfter.dev !== grant.identity.dev || stAfter.ino !== grant.identity.ino) + ) { + return this.customRootUnavailable('binding-moved'); + } + return null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + throw err; + } + } + + /** Path vs held parent inode vs grant after fd-relative create. */ + private async assertHeldCustomParent(held: { + dev: number; + ino: number; + }): Promise | null> { + const parent = path.dirname(this.root); + try { + const st = await fs.promises.lstat(parent); + if (st.isSymbolicLink() || !st.isDirectory()) return this.customRootUnavailable('disk-missing'); + if (held.ino !== 0 && (st.dev !== held.dev || st.ino !== held.ino)) { + return this.customRootUnavailable('binding-moved'); + } + return this.inspectCustomParent(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + throw err; + } + } + /* ── 打开与状态 ─────────────────────────────────────────────────── */ /** @@ -352,19 +430,99 @@ export class LibraryVault { if (this.invalidated) { return fail('LIBRARY_UNAVAILABLE', 'Library 实例已作废(owner 切换/宿主收口);请重新 open'); } + let customUsage: UsageLedger | null = null; try { - await fs.promises.mkdir(this.root, { recursive: true }); - await fs.promises.mkdir(this.tmpDir, { recursive: true }); - await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); + if ((this.deps.locationKind ?? 'default') === 'custom') { + const before = await this.inspectCustomParent(); + if (before) return before; + let parentHandle: fs.promises.FileHandle | null = null; + try { + const parent = path.dirname(this.root); + const dirSeg = path.basename(this.root); + const metaGhost = this.deps.ghostId || dirSeg; + const metaJson = JSON.stringify({ + version: 1, + ghostId: metaGhost, + createdAt: this.now(), + }); + let openFlags = fs.constants.O_RDONLY; + if (fs.constants.O_DIRECTORY) openFlags |= fs.constants.O_DIRECTORY; + if (fs.constants.O_NOFOLLOW) openFlags |= fs.constants.O_NOFOLLOW; + try { + parentHandle = await fs.promises.open(parent, openFlags); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.customRootUnavailable('disk-missing'); + throw err; + } + const held = await parentHandle.stat(); + if (!held.isDirectory()) return this.customRootUnavailable('disk-missing'); + const heldId = { dev: held.dev, ino: held.ino }; + const heldBefore = await this.assertHeldCustomParent(heldId); + if (heldBefore) return heldBefore; + const existing = await (this.deps.openExistingCustom ?? openExistingCustomLibrary)({ + parentFd: parentHandle.fd, + ghostId: dirSeg, + }); + if (existing.ok) { + this.meta = existing.meta; + if (existing.usage) customUsage = existing.usage; + } else if (existing.code === 'MISSING') { + const tree = await (this.deps.initCustomTree ?? initCustomLibraryTree)({ + parentFd: parentHandle.fd, + ghostId: dirSeg, + metaJson, + }); + if (!tree.ok) { + this.state = 'unavailable'; + this.unavailableReason = 'permission'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } + if (tree.createdMeta) { + const parsed = JSON.parse(metaJson) as LibraryMeta; + if ( + typeof parsed === 'object' && parsed !== null && parsed.version === 1 && + typeof parsed.ghostId === 'string' && typeof parsed.createdAt === 'number' + ) { + this.meta = parsed; + } + } + } else { + this.state = 'unavailable'; + this.unavailableReason = existing.code === 'CORRUPT' ? 'corrupt' : 'permission'; + this.opened = true; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } + const afterTree = await this.assertHeldCustomParent(heldId); + if (afterTree) return afterTree; + } finally { + if (parentHandle) { + try { + await parentHandle.close(); + } catch { + /* still close */ + } + } + } + } else { + await fs.promises.mkdir(this.root, { recursive: true }); + await fs.promises.mkdir(this.tmpDir, { recursive: true }); + await fs.promises.mkdir(path.join(this.metaDir, 'backups'), { recursive: true }); + } } catch (err) { + if ((this.deps.locationKind ?? 'default') === 'custom' && (err as NodeJS.ErrnoException).code === 'ENOENT') { + return this.customRootUnavailable(); + } this.state = 'unavailable'; this.unavailableReason = 'permission'; this.deps.log?.warn('library open: cannot create root', { error: err instanceof Error ? err.message : String(err) }); return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } - await this.sweepStaleTmp(); + const customOpen = (this.deps.locationKind ?? 'default') === 'custom'; + if (!customOpen) await this.sweepStaleTmp(); - // meta:已存在必须可解析(不可用 ≠ 空);不存在则首建。 + // meta:已存在必须可解析(不可用 ≠ 空);不存在则首建。custom 首次 open 用 held-fd 已写的 meta,不再 path 写。 + if (!this.meta) { try { const raw = await fs.promises.readFile(this.metaFile, 'utf8'); const parsed = JSON.parse(raw) as LibraryMeta; @@ -377,6 +535,12 @@ export class LibraryVault { this.meta = parsed; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + if (customOpen) { + this.opened = true; + this.state = 'unavailable'; + this.unavailableReason = 'permission'; + return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; + } this.meta = { version: 1, ghostId: this.deps.ghostId ?? '', createdAt: this.now() }; const w = await this.writeMetaUnlocked(); if (w) return w; @@ -388,9 +552,11 @@ export class LibraryVault { return { ok: true as const, state: this.state, reason: this.unavailableReason, usedBytes: 0, fileCount: 0 }; } } + } - // 用量:账本读不出就全量重扫(账本是缓存,真身是文件树)。 - let ledger: UsageLedger | null = null; + // 用量:合法账本只读复用;坏/缺才 scan。custom 首次 open 不 persist/unlink。 + let ledger: UsageLedger | null = customUsage; + if (!ledger) { try { const raw = JSON.parse(await fs.promises.readFile(this.usageFile, 'utf8')) as UsageLedger; if (typeof raw === 'object' && raw !== null && typeof raw.files === 'number' && typeof raw.bytes === 'number') { @@ -399,6 +565,7 @@ export class LibraryVault { } catch { /* 损坏/缺失 → 重扫 */ } + } if (!ledger) { const scanned = await this.scanUsageUnlocked(); if (scanned.tripped) { @@ -406,7 +573,7 @@ export class LibraryVault { this.usage = { files: scanned.files, bytes: scanned.bytes, updatedAt: this.now(), mutations: 0 }; } else { this.usage = { files: scanned.files, bytes: scanned.bytes, updatedAt: this.now(), mutations: 0 }; - await this.persistUsageUnlocked(); + if (!customOpen) await this.persistUsageUnlocked(); } } else { this.usage = ledger; diff --git a/docs/dev-rules/plugin-library-storage.md b/docs/dev-rules/plugin-library-storage.md index f511ebf66cb..c2ece161e05 100644 --- a/docs/dev-rules/plugin-library-storage.md +++ b/docs/dev-rules/plugin-library-storage.md @@ -45,7 +45,15 @@ backups)对插件不可达——路径语法段首不许点,协议层天然 1. **不可用 ≠ 空**:meta 损坏 → `unavailable(corrupt)`;binding 漂移 → `binding-moved` / `disk-missing`。宿主不自动重建、不清空、不回退写默认根、 - 不触发 GC、不判素材已删。插件侧同样语义写进了 FORGE_GUIDE。 + 不触发 GC、不判素材已删。缓存中的 custom 会话在真实根消失后必须现解 + binding:`open`/`status` 报 unavailable,不得 `mkdir` 重建空库。custom vault.open + 不得 recursive mkdir 已消失的用户父目录(父目录不在 → `disk-missing`);仅在父目录仍在时 + 允许创建 `/` 子目录。建根边界须复核 binding 的 realpath/dev/ino:同路径新对象是 + `binding-moved`,不得初始化空库或把 extraDir 授权到错误根。已挂 extraDir 后若 open 进入 drift,必须 + 实际撤 grant。自动 open 失败要把 drift 记进 session,同盘归位后仅 `status` 也须恢复。合法 default + 首次创建仍可 mkdir。被 rename 走的原件仍在旧目录,不称已删除。同一磁盘对象归位后既有 `open`/`status` + 恢复,删后重建的同路径是 `binding-moved` 不是原盘回归。Windows st_ino=0 检不出同路径重建,已知限制。 + 插件侧同样语义写进了 FORGE_GUIDE。 2. **卸载不删**:uninstall 只标 orphaned + 作废会话;binding 保留(用户亲选 事实不因重装消失)。删除 = 设置页独立破坏性确认 + `trashGhostLibrary` (rename 进回收站,漂移时 NOT_FOUND 不误删)。内置插件退役清理 diff --git a/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts b/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts index 6d52666cf4f..7260a910595 100644 --- a/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts +++ b/packages/maker-core/src/agents/pi/__tests__/pi-startsession-cleanup.test.ts @@ -1676,22 +1676,59 @@ describe('PiAgent.startSession failure cleanup (mocked pi process)', () => { }); it('preserves system denial when a durable Subagent approval resolver fails', async () => { + const t0 = performance.now(); + const events: Array<{ t: number; k: string; n?: number }> = []; + const mark = (k: string, n?: number): void => { + events.push({ t: Math.round(performance.now() - t0), k, ...(n === undefined ? {} : { n }) }); + }; const run = pendingSubagentRun({ toolName: 'write', input: { path: 'a.txt' }, }, {}, 'input'); - mockRunDiscovery().mockResolvedValue([run]); - const control = vi.spyOn(piSubagentRuns, 'controlPiSubagentRuns').mockResolvedValue(1); - const handle = await new PiAgent(buildDeps()).startSession(opts()); - handle.setInteractionResolver(vi.fn(async () => { throw new Error('resolver failed'); })); - - await vi.waitFor(() => expect(control).toHaveBeenCalledWith( - expect.any(String), - run.taskId, - 'approval', - expect.objectContaining({ value: 'system-deny' }), - ), { timeout: 3_000 }); - await handle.close(); + const list = mockRunDiscovery().mockImplementation(async () => { + mark('list', list.mock.calls.length); + return [run]; + }); + const control = vi.spyOn(piSubagentRuns, 'controlPiSubagentRuns').mockImplementation(async () => { + mark('control', control.mock.calls.length + 1); + return 1; + }); + const resolver = vi.fn(async () => { + mark('resolver-throw'); + throw new Error('resolver failed'); + }); + let handle: Awaited> | undefined; + try { + handle = await new PiAgent(buildDeps()).startSession(opts()); + mark('startSession-returned'); + mark('resolver-install-start'); + handle.setInteractionResolver(resolver); + mark('resolver-install-end'); + mark('waitFor-start'); + try { + await vi.waitFor(() => expect(control).toHaveBeenCalledWith( + expect.any(String), + run.taskId, + 'approval', + expect.objectContaining({ value: 'system-deny' }), + ), { timeout: 3_000 }); + mark('waitFor-pass'); + } catch (err) { + mark('waitFor-fail', control.mock.calls.length); + console.error('[pi-startsession-cleanup.diag]', JSON.stringify({ + host: `${process.platform} ${process.version}`, + notLinuxProof: process.platform !== 'linux', + events, + listCalls: list.mock.calls.length, + controlCalls: control.mock.calls.length, + resolverCalls: resolver.mock.calls.length, + })); + throw err; + } + } finally { + await handle?.close(); + mark('handle-closed'); + } }); it('never answers durable Subagent approvals owned by another runtime', async () => {