Skip to content

Commit fdb4dc0

Browse files
committed
fix(memory-wiki): preserve only the exact unreadable subtree when pruning unsafe-local
1 parent 8db08de commit fdb4dc0

2 files changed

Lines changed: 76 additions & 39 deletions

File tree

extensions/memory-wiki/src/unsafe-local.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,46 @@ describe("syncMemoryWikiUnsafeLocalSources", () => {
190190
}
191191
});
192192

193+
it("prunes a deleted readable sibling while a nested directory is unreadable", async () => {
194+
// Root bypasses permission bits, so the unreadable-directory simulation
195+
// below would not actually fail; skip rather than assert a false pass.
196+
if (process.getuid?.() === 0) {
197+
return;
198+
}
199+
200+
const privateDir = await createPrivateDir("private-nested-sibling");
201+
const nestedDir = path.join(privateDir, "nested");
202+
await fs.mkdir(nestedDir, { recursive: true });
203+
await fs.writeFile(path.join(nestedDir, "deep.md"), "# deep\n", "utf8");
204+
const siblingPath = path.join(privateDir, "sibling.md");
205+
await fs.writeFile(siblingPath, "# sibling\n", "utf8");
206+
207+
const { config } = await createVault({
208+
rootDir: nextCaseRoot("nested-sibling-vault"),
209+
config: {
210+
vaultMode: "unsafe-local",
211+
unsafeLocal: {
212+
allowPrivateMemoryCoreAccess: true,
213+
paths: [privateDir],
214+
},
215+
},
216+
});
217+
218+
const first = await syncMemoryWikiUnsafeLocalSources(config);
219+
expect(first.importedCount).toBe(2);
220+
221+
// The nested directory goes offline while a readable sibling file is deleted.
222+
// Only the unavailable subtree is preserved; the sibling's stale page prunes.
223+
await fs.rm(siblingPath);
224+
await fs.chmod(nestedDir, 0o000);
225+
try {
226+
const second = await syncMemoryWikiUnsafeLocalSources(config);
227+
expect(second.removedCount).toBe(1);
228+
} finally {
229+
await fs.chmod(nestedDir, 0o755);
230+
}
231+
});
232+
193233
it("keeps the imported page when an explicit configured file is transiently unavailable", async () => {
194234
const privateDir = await createPrivateDir("private-explicit");
195235
const secretPath = path.join(privateDir, "secret.md");

extensions/memory-wiki/src/unsafe-local.ts

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -45,27 +45,25 @@ function detectFenceLanguage(filePath: string): string {
4545
return "markdown";
4646
}
4747

48-
// `readable` is false when this directory or any descendant could not be read
48+
// `unreadableDirs` lists the exact directories whose contents could not be read
4949
// (an unmounted/undocked volume), which is distinct from a readable-but-empty
50-
// directory. It propagates up so a transiently-gone nested mount is not mistaken
51-
// for deletion; callers use it to skip pruning during an outage (#97523).
50+
// directory. They bubble up so the caller preserves only the unavailable subtree
51+
// during an outage, while readable siblings still prune normally (#97523).
5252
async function listAllowedFilesRecursive(
5353
rootDir: string,
54-
): Promise<{ files: string[]; readable: boolean }> {
54+
): Promise<{ files: string[]; unreadableDirs: string[] }> {
5555
const entries = await fs.readdir(rootDir, { withFileTypes: true }).catch(() => null);
5656
if (entries === null) {
57-
return { files: [], readable: false };
57+
return { files: [], unreadableDirs: [rootDir] };
5858
}
5959
const files: string[] = [];
60-
let readable = true;
60+
const unreadableDirs: string[] = [];
6161
for (const entry of entries) {
6262
const fullPath = path.join(rootDir, entry.name);
6363
if (entry.isDirectory()) {
6464
const nested = await listAllowedFilesRecursive(fullPath);
6565
files.push(...nested.files);
66-
if (!nested.readable) {
67-
readable = false;
68-
}
66+
unreadableDirs.push(...nested.unreadableDirs);
6967
continue;
7068
}
7169
if (
@@ -75,32 +73,30 @@ async function listAllowedFilesRecursive(
7573
files.push(fullPath);
7674
}
7775
}
78-
return { files: files.toSorted((left, right) => left.localeCompare(right)), readable };
76+
return { files: files.toSorted((left, right) => left.localeCompare(right)), unreadableDirs };
7977
}
8078

8179
async function collectUnsafeLocalArtifacts(
8280
configuredPaths: string[],
83-
): Promise<{ artifacts: UnsafeLocalArtifact[]; unreadableRoots: string[] }> {
81+
): Promise<{ artifacts: UnsafeLocalArtifact[]; unreadablePaths: string[] }> {
8482
const artifacts: UnsafeLocalArtifact[] = [];
85-
// Configured roots that could not be fully read (undocked drive, unmounted
86-
// NAS, not-yet-mounted nested mount, or a missing explicit file) are
87-
// transiently absent, not deleted. The caller preserves imported entries under
88-
// these roots from pruning while still cleaning up readable roots (#97523).
89-
const unreadableRoots: string[] = [];
83+
// Exact paths that could not be read (undocked drive, unmounted NAS,
84+
// not-yet-mounted nested mount, or a missing explicit file) are transiently
85+
// absent, not deleted. The caller preserves imported entries under these paths
86+
// from pruning while readable siblings still clean up deletions (#97523).
87+
const unreadablePaths: string[] = [];
9088
for (const configuredPath of configuredPaths) {
9189
const absoluteConfiguredPath = path.resolve(configuredPath);
9290
const stat = await fs.stat(absoluteConfiguredPath).catch(() => null);
9391
if (!stat) {
94-
unreadableRoots.push(absoluteConfiguredPath);
92+
unreadablePaths.push(absoluteConfiguredPath);
9593
continue;
9694
}
9795
if (stat.isDirectory()) {
9896
const listing = await listAllowedFilesRecursive(absoluteConfiguredPath);
99-
// A nested mount that is gone still imports the files we can read, but the
100-
// root is marked unreadable so its entries are not pruned during the outage.
101-
if (!listing.readable) {
102-
unreadableRoots.push(absoluteConfiguredPath);
103-
}
97+
// A gone nested mount still imports the files we can read; only the exact
98+
// unreadable subtrees are preserved so readable siblings still prune.
99+
unreadablePaths.push(...listing.unreadableDirs);
104100
for (const absolutePath of listing.files) {
105101
artifacts.push({
106102
syncKey: await resolveArtifactKey(absolutePath),
@@ -125,33 +121,34 @@ async function collectUnsafeLocalArtifacts(
125121
for (const artifact of artifacts) {
126122
deduped.set(artifact.syncKey, artifact);
127123
}
128-
return { artifacts: [...deduped.values()], unreadableRoots };
124+
return { artifacts: [...deduped.values()], unreadablePaths };
129125
}
130126

131-
function isPathAtOrWithin(target: string, root: string): boolean {
132-
if (target === root) {
127+
function isPathAtOrWithin(target: string, base: string): boolean {
128+
if (target === base) {
133129
return true;
134130
}
135-
const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
136-
return target.startsWith(rootWithSep);
131+
const baseWithSep = base.endsWith(path.sep) ? base : `${base}${path.sep}`;
132+
return target.startsWith(baseWithSep);
137133
}
138134

139-
// Keep imported entries whose source lives under a transiently unreadable root
135+
// Keep imported entries whose source lives under a transiently unreadable path
140136
// out of the prune by marking their keys active, so a temporary outage cannot
141-
// delete their pages or human-notes while readable roots still clean up (#97523).
142-
function preserveEntriesUnderUnreadableRoots(params: {
137+
// delete their pages or human-notes while readable siblings still clean up
138+
// genuinely deleted files (#97523).
139+
function preserveEntriesUnderUnreadablePaths(params: {
143140
state: Awaited<ReturnType<typeof readMemoryWikiSourceSyncState>>;
144-
unreadableRoots: string[];
141+
unreadablePaths: string[];
145142
activeKeys: Set<string>;
146143
}): void {
147-
if (params.unreadableRoots.length === 0) {
144+
if (params.unreadablePaths.length === 0) {
148145
return;
149146
}
150147
for (const [syncKey, entry] of Object.entries(params.state.entries)) {
151148
if (entry.group !== "unsafe-local") {
152149
continue;
153150
}
154-
if (params.unreadableRoots.some((root) => isPathAtOrWithin(entry.sourcePath, root))) {
151+
if (params.unreadablePaths.some((base) => isPathAtOrWithin(entry.sourcePath, base))) {
155152
params.activeKeys.add(syncKey);
156153
}
157154
}
@@ -268,7 +265,7 @@ export async function syncMemoryWikiUnsafeLocalSources(
268265
};
269266
}
270267

271-
const { artifacts, unreadableRoots } = await collectUnsafeLocalArtifacts(
268+
const { artifacts, unreadablePaths } = await collectUnsafeLocalArtifacts(
272269
config.unsafeLocal.paths,
273270
);
274271
const state = await readMemoryWikiSourceSyncState(config.vault.path);
@@ -292,12 +289,12 @@ export async function syncMemoryWikiUnsafeLocalSources(
292289
}),
293290
);
294291

295-
// A transiently unreadable root collects zero artifacts the same way a real
292+
// A transiently unreadable path collects zero artifacts the same way a real
296293
// deletion does, so its entries would otherwise be pruned, hard-deleting their
297-
// pages and human-notes blocks with nothing left to restore. Preserve those
298-
// entries per-root, then prune normally so readable roots still clean up their
299-
// genuinely deleted files. See #97523.
300-
preserveEntriesUnderUnreadableRoots({ state, unreadableRoots, activeKeys });
294+
// pages and human-notes blocks with nothing left to restore. Preserve only the
295+
// entries under the exact unreadable subtrees, then prune normally so readable
296+
// siblings still clean up their genuinely deleted files. See #97523.
297+
preserveEntriesUnderUnreadablePaths({ state, unreadablePaths, activeKeys });
301298
const removedCount = await pruneImportedSourceEntries({
302299
vaultRoot: config.vault.path,
303300
group: "unsafe-local",

0 commit comments

Comments
 (0)