Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
562 changes: 518 additions & 44 deletions apps/desktop/src/main/cindy-brain/GhostManager.ts

Large diffs are not rendered by default.

718 changes: 702 additions & 16 deletions apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'node:path';

import { afterEach, describe, expect, it, vi } from 'vitest';

import { provisionBuiltinGhosts } from '../builtinGhostProvisioner.js';
import { fingerprintDirContent, provisionBuiltinGhosts } from '../builtinGhostProvisioner.js';

const tempDirs: string[] = [];

Expand All @@ -20,6 +20,253 @@ afterEach(async () => {
);
});

describe('fingerprintDirContent', () => {
/** 建链接;该环境无权限时返回 false 让调用方跳过(判定逻辑与其他平台同源)。 */
async function tryLink(target: string, linkPath: string): Promise<boolean> {
try {
await fs.promises.symlink(
target,
linkPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
return true;
} catch {
return false;
}
}

it('flags a planted link instead of folding it into the content hash', async () => {
const root = await makeTempDir();
const installed = path.join(root, 'installed');
const outside = path.join(root, 'outside');
await fs.promises.mkdir(installed, { recursive: true });
await fs.promises.mkdir(outside, { recursive: true });
await fs.promises.writeFile(path.join(installed, 'main.js'), '// brain');
await fs.promises.writeFile(path.join(outside, 'leak.txt'), 'outside bytes');

const before = await fingerprintDirContent(installed);
expect(before.hasNonRegularEntry).toBe(false);

if (!(await tryLink(outside, path.join(installed, 'linked')))) return;

const after = await fingerprintDirContent(installed);
// 类型状态独立于哈希:内容哈希不变(链接没有内容),但状态位翻过来。
expect(after.hasNonRegularEntry).toBe(true);
expect(after.hash).toBe(before.hash);
});

it('keeps type out of the hash so a sentinel-valued regular file stays distinguishable', async () => {
// **这是契约/文档用例,不是回归用例**:它无法表达修复前的状态(那时没有
// hasNonRegularEntry 字段,代码都编译不过),已实测在"sentinel 进哈希 + 有状态位"
// 的混合态下同样会绿。真正的回归点是本文件下面那条端到端用例
// (`re-seeds when a seed file was replaced by a link...`)—— 判据落在 provisioner
// 的决策上,才能在旧实现下变红。
// 这里只钉住契约:类型信息不掺进字节流,所以"内容恰为 sentinel 的普通文件"与
// "同名链接"始终可区分。
const root = await makeTempDir();
const withFile = path.join(root, 'with-file');
const withLink = path.join(root, 'with-link');
const target = path.join(root, 'target');
await fs.promises.mkdir(withFile, { recursive: true });
await fs.promises.mkdir(withLink, { recursive: true });
await fs.promises.mkdir(target, { recursive: true });
await fs.promises.writeFile(path.join(withFile, 'entry'), 'non-regular');

if (!(await tryLink(target, path.join(withLink, 'entry')))) return;

const fileSide = await fingerprintDirContent(withFile);
const linkSide = await fingerprintDirContent(withLink);
expect(fileSide.hasNonRegularEntry).toBe(false);
expect(linkSide.hasNonRegularEntry).toBe(true);
// 即便两侧哈希相同也不会被误判为一致 —— 判定还要看类型状态。
expect(
fileSide.hash === linkSide.hash && fileSide.hasNonRegularEntry === linkSide.hasNonRegularEntry,
).toBe(false);
});

it('matches identical link-free directories under the v2 encoding', async () => {
// 同一套 v2 编码下,内容相同的普通目录必须得到相同指纹;这个用例不主张与旧版
// 摘要兼容(v2 framing 本来就会主动改变旧摘要)。
const root = await makeTempDir();
const a = path.join(root, 'a');
const b = path.join(root, 'b');
for (const dir of [a, b]) {
await fs.promises.mkdir(path.join(dir, 'nested'), { recursive: true });
await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain');
await fs.promises.writeFile(path.join(dir, 'nested', 'x.txt'), 'x');
await fs.promises.writeFile(path.join(dir, '.disabled'), '');
}
const fa = await fingerprintDirContent(a);
const fb = await fingerprintDirContent(b);
expect(fa.hash).toBe(fb.hash);
expect(fa.hasNonRegularEntry).toBe(false);
});
});

describe('builtinGhostProvisioner 安装目录被塞入链接时重新播种', () => {
it('re-seeds when a seed file was replaced by a link, even if its bytes could spoof a hash sentinel', async () => {
// 决定性用例:判定必须落在 provisioner 的**决策**上,而不是指纹结构上。
// 把非普通条目当 sentinel 喂进哈希的实现里,种子文件 `entry` 内容恰为该 sentinel
// 时,同名链接与它的摘要完全相等(已实测),于是安装目录被判成"逐字节一致"而跳过
// 重新播种 —— 目录永远修不回来,随后批准又必然失败,插件卡在不可用。
const root = await makeTempDir();
const seedRoot = path.join(root, 'seeds');
const repoRoot = path.join(root, 'installed');
const seedDir = path.join(seedRoot, 'linked-seed');
const installedDir = path.join(repoRoot, 'linked-seed');
const outside = path.join(root, 'outside');
const manifest = JSON.stringify({
schemaVersion: 2,
id: 'linked-seed',
name: 'Linked seed',
version: '1.0.0',
kind: 'chip',
entry: 'main.js',
slots: ['tool'],
tools: [{ name: 'run', description: 'Run it' }],
});
await fs.promises.mkdir(seedDir, { recursive: true });
await fs.promises.mkdir(installedDir, { recursive: true });
await fs.promises.mkdir(outside, { recursive: true });
await fs.promises.writeFile(path.join(outside, 'leak.txt'), 'outside bytes');
for (const dir of [seedDir, installedDir]) {
await fs.promises.writeFile(path.join(dir, 'ghost.json'), manifest);
await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain');
// 内容刻意等于旧实现的 sentinel 字符串。
await fs.promises.writeFile(path.join(dir, 'entry'), 'non-regular');
}

// 安装侧把这个普通文件换成同名链接。
await fs.promises.rm(path.join(installedDir, 'entry'));
try {
await fs.promises.symlink(
outside,
path.join(installedDir, 'entry'),
process.platform === 'win32' ? 'junction' : 'dir',
);
} catch {
return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。
}

const outcome = await provisionBuiltinGhosts({
seedRootDirs: [seedRoot],
repoRootDir: repoRoot,
log: { info: vi.fn(), warn: vi.fn() },
});

expect(outcome.updated.map((m) => m.id)).toContain('linked-seed');
expect(outcome.skipped).not.toContain('linked-seed');
// 重新播种后安装目录回到随包字节:链接消失,普通文件回来。
expect((await fs.promises.lstat(path.join(installedDir, 'entry'))).isFile()).toBe(true);
});
});

describe('builtinGhostProvisioner 安装目录被塞入点开头链接时重新播种', () => {
it('re-seeds when a dot-named link was planted, even though dot entries stay out of the hash', async () => {
// 回归点:指纹跳过点开头条目(`.disabled` 是用户状态不是内容),上一版对它们
// 直接 continue —— 于是名为 `.x` 的链接既不进指纹也不翻类型状态位,安装目录被
// 塞进链接却判成"与种子逐字节相同"而跳过播种。现在类型判定排在点开头过滤之前。
const root = await makeTempDir();
const seedRoot = path.join(root, 'seeds');
const repoRoot = path.join(root, 'installed');
const seedDir = path.join(seedRoot, 'dotlink');
const installedDir = path.join(repoRoot, 'dotlink');
const outside = path.join(root, 'outside');
const manifest = JSON.stringify({
schemaVersion: 2,
id: 'dotlink',
name: 'Dot link',
version: '1.0.0',
kind: 'chip',
entry: 'main.js',
slots: ['tool'],
tools: [{ name: 'run', description: 'Run it' }],
});
await fs.promises.mkdir(seedDir, { recursive: true });
await fs.promises.mkdir(installedDir, { recursive: true });
await fs.promises.mkdir(outside, { recursive: true });
for (const dir of [seedDir, installedDir]) {
await fs.promises.writeFile(path.join(dir, 'ghost.json'), manifest);
await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain');
}
// 内容字节完全一致,唯一差别是安装侧多了一条点开头链接。
try {
await fs.promises.symlink(
outside,
path.join(installedDir, '.sneaky'),
process.platform === 'win32' ? 'junction' : 'dir',
);
} catch {
return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。
}

const outcome = await provisionBuiltinGhosts({
seedRootDirs: [seedRoot],
repoRootDir: repoRoot,
log: { info: vi.fn(), warn: vi.fn() },
});

expect(outcome.updated.map((m) => m.id)).toContain('dotlink');
expect(outcome.skipped).not.toContain('dotlink');
// 重新播种后链接消失(点开头条目不随种子复制),下一轮启动即判一致、不再反复播种。
expect(fs.existsSync(path.join(installedDir, '.sneaky'))).toBe(false);
});
});

describe('builtinGhostProvisioner 坏种子 fail closed', () => {
it('种子含非普通条目时跳过,不交换目录也不申请批准', async () => {
const root = await makeTempDir();
const seedRoot = path.join(root, 'seeds');
const repoRoot = path.join(root, 'installed');
const seedDir = path.join(seedRoot, 'bad-seed');
const outside = path.join(root, 'outside');
await fs.promises.mkdir(seedDir, { recursive: true });
await fs.promises.mkdir(outside, { recursive: true });
await fs.promises.writeFile(
path.join(seedDir, 'ghost.json'),
JSON.stringify({
schemaVersion: 2,
id: 'bad-seed',
name: 'Bad seed',
version: '1.0.0',
kind: 'chip',
entry: 'main.js',
slots: ['tool'],
tools: [{ name: 'run', description: 'Run it' }],
}),
);
await fs.promises.writeFile(path.join(seedDir, 'main.js'), '// brain');
try {
await fs.promises.symlink(
outside,
path.join(seedDir, '.linked'),
process.platform === 'win32' ? 'junction' : 'dir',
);
} catch {
return;
}
const warn = vi.fn();

const outcome = await provisionBuiltinGhosts({
seedRootDirs: [seedRoot],
repoRootDir: repoRoot,
log: { info: vi.fn(), warn },
});

expect(outcome.skipped).toContain('bad-seed');
expect(outcome.installed).toEqual([]);
expect(outcome.approved).toEqual([]);
expect(fs.existsSync(path.join(repoRoot, 'bad-seed'))).toBe(false);
expect(warn).toHaveBeenCalledWith(
'builtin ghost provisioning failed',
expect.objectContaining({
id: 'bad-seed',
error: expect.stringContaining('non-regular'),
}),
);
});
});

describe('builtinGhostProvisioner locale validation', () => {
it('locale 资源翻译错位时跳过官方种子,不把损坏翻译播种给用户', async () => {
const root = await makeTempDir();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function makeGhost(): InstalledGhost {
},
dir: ghostDir,
enabled: true,
approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' },
};
}

Expand Down
Loading