Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions apps/server/src/modules/storage/backends/disk/blob-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@

import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -133,4 +136,26 @@ describe('DiskBlobStore temp file hygiene', () => {
rmSync(otherRoot, { recursive: true, force: true });
}
});

it.skipIf(process.platform === 'win32')(
'refuses to delete blobs through a symlinked scope root',
async () => {
const target = mkdtempSync(path.join(tmpdir(), 'huabu-blob-outside-'));
const artifact = path.join(target, '.artifacts', 'keep.bin');
mkdirSync(path.dirname(artifact), { recursive: true });
writeFileSync(artifact, 'bytes');
symlinkSync(target, path.join(root, 'symlink-canvas'), 'dir');

try {
const scope = new DiskBlobStore().scope({
kind: 'canvas',
canvasId: 'symlink-canvas',
});
await expect(scope.deleteAll()).rejects.toThrow(/symbolic link/i);
expect(existsSync(artifact)).toBe(true);
} finally {
rmSync(target, { recursive: true, force: true });
}
},
);
});
55 changes: 52 additions & 3 deletions apps/server/src/modules/storage/backends/disk/blob-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
*/

import { randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import {
createReadStream,
createWriteStream,
existsSync,
lstatSync,
} from 'node:fs';
import {
mkdir,
readdir,
Expand All @@ -26,7 +31,12 @@ import {
import path from 'node:path';
import { pipeline } from 'node:stream/promises';

import { artifactsDir } from './layout.js';
import { destructiveCanvasDirName } from './canvas-dirs.js';
import {
artifactsDir,
ARTIFACTS_DIR_NAME,
SPACE_JSON_FILENAME,
} from './layout.js';
import { renameOverWithRetry } from '../../../../utils/fs.js';
import { getWorkspacePath } from '../../../workspace.js';
import { createBlobLease, normalizeBlobName } from '../../ports/blob.js';
Expand Down Expand Up @@ -94,6 +104,43 @@ class DiskBlobScope implements BlobScope {
return scopeDir(this.#ref);
}

#resolveDeleteDir(): string | null {
const active = path.resolve(getWorkspacePath());
if (active !== this.#workspacePath) {
throw new Error(
`DiskBlobScope(${this.#ref.canvasId}) belongs to an inactive workspace. ` +
`Resolve a fresh scope after workspace activation.`,
);
}
const target = destructiveCanvasDirName(this.#ref.canvasId);
if (target === null) return null;
const root = path.resolve(this.#workspacePath, target.filename);
const resolved = path.join(root, ARTIFACTS_DIR_NAME);
if (!resolved.startsWith(`${this.#workspacePath}${path.sep}`)) {
throw new Error(
`Blob scope escapes the active Workspace: "${this.#ref.canvasId}"`,
);
}
try {
if (lstatSync(root).isSymbolicLink()) {
throw new Error(
`Refusing to delete blobs through a symbolic link: "${root}"`,
);
}
} catch (error) {
if (!isMissing(error)) throw error;
}
// A Space may have appeared in a previously orphaned directory after the
// fresh scan. Refuse before the first await instead of deleting its blobs.
if (
target.kind === 'orphan' &&
existsSync(path.join(root, SPACE_JSON_FILENAME))
) {
return null;
}
return resolved;
}

async #headAt(dir: string, name: string): Promise<BlobInfo | null> {
const safe = normalizeBlobName(name);
try {
Expand Down Expand Up @@ -229,7 +276,9 @@ class DiskBlobScope implements BlobScope {
}

async deleteAll(): Promise<void> {
await rm(this.#resolveDir(), { recursive: true, force: true });
const dir = this.#resolveDeleteDir();
if (dir === null) return;
await rm(dir, { recursive: true, force: true });
}
}

Expand Down
43 changes: 42 additions & 1 deletion apps/server/src/modules/storage/backends/disk/canvas-dirs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
import { existsSync, readdirSync, renameSync, statSync } from 'node:fs';
import path from 'node:path';

import { SPACE_JSON_FILENAME, WORLD_CANVAS_DIR_NAME } from './layout.js';
import {
SPACE_JSON_FILENAME,
WORLD_CANVAS_DIR_NAME,
WORKSPACE_SETTING_DIR_NAME,
} from './layout.js';
import { NameIndex, type NameIndexResult } from './name-index.js';
import { readJsonStrict, sanitizeId } from '../../../../utils/fs.js';
import {
Expand Down Expand Up @@ -149,6 +153,43 @@ export function canvasDirName(canvasId: string): string {
return index.get(canvasId)?.filename ?? canvasId;
}

/**
* Resolve a directory for destructive blob cleanup.
*
* Missing stable ids may still own blobs in the legacy id-named directory,
* but that fallback must never alias another Space or a Workspace-owned
* directory. Destructive callers get a fresh scan so a stale name index
* cannot authorize the wrong target.
*/
export type DestructiveCanvasDirTarget =
| { readonly kind: 'owned'; readonly filename: string }
| { readonly kind: 'orphan'; readonly filename: string };

export function destructiveCanvasDirName(
canvasId: string,
): DestructiveCanvasDirTarget | null {
const safeId = sanitizeId(canvasId, 'canvasId');
scanWorkspace();
if (worldEntry?.id === safeId) {
return { kind: 'owned', filename: WORLD_CANVAS_DIR_NAME };
}

const owned = index.get(safeId);
if (owned) return { kind: 'owned', filename: owned.filename };
// NameIndex's secondary key is the normalized on-disk filename, not the
// display title. A hit here means the fallback belongs to another Space.
if (index.findByName(safeId)) return null;

const normalized = normalizeForCompare(safeId);
if (
normalized === normalizeForCompare(WORLD_CANVAS_DIR_NAME) ||
normalized === normalizeForCompare(WORKSPACE_SETTING_DIR_NAME)
) {
return null;
}
return { kind: 'orphan', filename: safeId };
}

/** Ordinary user-visible Spaces only. */
export function listCanvasDirEntries(): CanvasDirEntry[] {
ensureScanned();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock('../../../workspace.js', () => ({

import {
canvasDirName,
destructiveCanvasDirName,
getWorldCanvasId,
isWorldCanvasId,
listAllCanvasDirEntries,
Expand Down Expand Up @@ -119,4 +120,36 @@ describe('World canvas directory indexing', () => {
'World canvas is missing or malformed',
);
});

it('resolves destructive fallbacks by stable ownership and disk filename', () => {
writeCanvas(
workspaceState.path,
'Alias_Victim',
'canvas-alias',
'Alias/Victim',
);

expect(destructiveCanvasDirName('canvas-alias')).toEqual({
kind: 'owned',
filename: 'Alias_Victim',
});
expect(destructiveCanvasDirName('Alias_Victim')).toBeNull();
expect(destructiveCanvasDirName('SETTING')).toBeNull();
expect(destructiveCanvasDirName('canvas-orphan')).toEqual({
kind: 'orphan',
filename: 'canvas-orphan',
});
});

it('refreshes a warm index before authorizing a destructive fallback', () => {
expect(listCanvasDirEntries()).toHaveLength(1);
writeCanvas(
workspaceState.path,
'FreshAlias',
'canvas-fresh',
'FreshAlias',
);

expect(destructiveCanvasDirName('FreshAlias')).toBeNull();
});
});
1 change: 1 addition & 0 deletions apps/server/src/modules/storage/backends/disk/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function canvasRoot(canvasId: string): string {
*/
export const SPACE_JSON_FILENAME = 'space.json';
export const WORLD_CANVAS_DIR_NAME = '.world';
export const WORKSPACE_SETTING_DIR_NAME = 'setting';

export function canvasJsonPath(canvasId: string): string {
return path.join(canvasRoot(canvasId), SPACE_JSON_FILENAME);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
eventsPath,
nodeFilePath,
nodesDir,
SPACE_JSON_FILENAME,
} from '../layout.js';
import { NameIndex } from '../name-index.js';
import { readValidCanvasFile } from '../space-record-validation.js';
Expand Down Expand Up @@ -1515,11 +1516,16 @@ export class CanvasStore {
/** Recursively delete the entire canvas directory. */
destroy(): boolean {
this.assertActiveWorkspace();
refreshCanvasDirIndex();
if (isWorldCanvasId(this.canvasId)) {
throw new Error('World canvas cannot be deleted');
}
const root = canvasRoot(this.canvasId);
if (!existsSync(root)) {
const record = readValidCanvasFile(
path.join(root, SPACE_JSON_FILENAME),
this.canvasId,
);
if (record === null) {
unregisterCanvasDir(this.canvasId);
this.invalidateNodeIndex();
clearSpaceNodeTombstones(this.#workspacePath, this.canvasId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,31 @@ describe('DiskSpaceRepository membership', () => {
await expect(held.worldId()).rejects.toThrow(/inactive workspace/i);
await expect(new DiskSpaceRepository().worldId()).resolves.toBe('world-b');
});

it('releases deletion admission when a queued session resumes in another Workspace', async () => {
const firstRoot = makeWorkspace('huabu-space-delete-release-a-');
seedWorld(firstRoot, 'world-a');
seedSpace(firstRoot, 'canvas-a', 'Alpha');
const spaces = new DiskSpaceRepository();
const first = await spaces.beginDelete({ canvasId: 'canvas-a' });
if (!first.ok) throw new Error('Expected ordinary Space deletion session');

const queued = spaces.beginDelete({ canvasId: 'canvas-a' });
await Promise.resolve();
const secondRoot = makeWorkspace('huabu-space-delete-release-b-');
seedWorld(secondRoot, 'world-b');
await first.session.abort();

await expect(queued).rejects.toThrow(/inactive workspace/i);

workspaceState.path = firstRoot;
resetStorageCache();
refreshCanvasDirIndex();
const retry = await spaces.beginDelete({ canvasId: 'canvas-a' });
if (!retry.ok)
throw new Error('Expected deletion admission to be released');
await retry.session.abort();
});
});

describe('DiskSpaceRepository lifecycle', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
forgetCanvasStore,
getCanvasStore,
} from './legacy/canvas-store-cache.js';
import { clearSpaceNodeTombstones } from './legacy/node-tombstones.js';
import { withSpaceDirHandlesReleased } from './space-dir-handles.js';
import { readValidCanvasFile } from './space-record-validation.js';
import { readDiskSpaceRecord } from './space-record.js';
Expand Down Expand Up @@ -160,11 +161,22 @@ export class DiskSpaceRepository implements SpaceRepository {
return { ok: false, reason: 'world-forbidden' };
}

const store = getCanvasStore(canvasId);
const release = await beginSpaceDeleteAdmission(
this.#workspacePath,
canvasId,
);
let store: ReturnType<typeof getCanvasStore> | null;
try {
this.#assertActiveWorkspace();
refreshCanvasDirIndex();
const existed = listAllCanvasDirEntries().some(
(entry) => entry.id === canvasId,
);
store = existed ? getCanvasStore(canvasId) : null;
} catch (error) {
release();
throw error;
}
let state: 'open' | 'finishing' | 'closed' = 'open';
const close = (): void => {
if (state === 'closed') return;
Expand All @@ -178,6 +190,11 @@ export class DiskSpaceRepository implements SpaceRepository {
}
state = 'finishing';
try {
if (!store) {
forgetCanvasStore(canvasId);
clearSpaceNodeTombstones(this.#workspacePath, canvasId);
return { ok: false as const, reason: 'not-found' as const };
}
const deleted = await withSpaceDirHandlesReleased(canvasId, () =>
store.destroy(),
);
Expand Down
Loading