fix(downloads): bind fresh archive reservations to owned files

This commit is contained in:
4gray committed 2026-09-08 05:16:27 +02:00
1 parent 86f7759719
commit 1dfafb404d
10 files changed
+123 -9

No files matched your search

+2
View File
@@ -1045,6 +1045,8 @@ so Retry can reserve a fresh path. A synchronous completion-commit boundary
rejects late pause/cancel commands before awaited cleanup and persistence.
Archive ownership includes device, inode and positive creation time to reject
reused inodes after unlink; old proofs without creation time remain untrusted.
Fresh reservations capture and journal ownership before the initial HTTP wait;
no preexisting partial is truncated without matching expected ownership.
Private cleanup captures are journaled before relocation, keeping failed
Remove/Clear/cancel cleanup retryable across restarts without hardlinks. Active
failures, promotion and startup share that cleanup; Remove waits for active
+2
View File
@@ -1856,6 +1856,8 @@ so Retry can reserve a fresh path. A synchronous completion-commit boundary
rejects late pause/cancel commands before awaited cleanup and persistence.
Archive ownership includes device, inode and positive creation time to reject
reused inodes after unlink; old proofs without creation time remain untrusted.
Fresh reservations capture and journal ownership before the initial HTTP wait;
no preexisting partial is truncated without matching expected ownership.
Private cleanup captures are journaled before relocation, keeping failed
Remove/Clear/cancel cleanup retryable across restarts without hardlinks. Active
failures, promotion and startup share that cleanup; Remove waits for active
@@ -67,8 +67,8 @@ export async function openCatchupOutput(
current.nlink !== 1 ||
(before && !sameArchiveFileIdentity(current, before)) ||
(before &&
expectedIdentity &&
!sameArchiveFileIdentity(current, expectedIdentity))
(!expectedIdentity ||
!sameArchiveFileIdentity(current, expectedIdentity)))
) {
throw new ArchivePartialReplacedError();
}
@@ -278,3 +278,12 @@ it('does not recover a same-size final from a reused inode or a legacy proof wit
)
).toBeUndefined();
});
it('never truncates a preexisting file without expected ownership', async () => {
const record = jest.fn();
await expect(
openCatchupOutput(filePath + '.part', undefined, record)
).rejects.toThrow('changed');
expect(record).not.toHaveBeenCalled();
expect(readFileSync(filePath + '.part', 'utf8')).toBe('owned bytes');
});
@@ -1,3 +1,4 @@
import { openCatchupOutput } from './download-catchup-output';
import {
mkdtemp,
lstat,
@@ -12,6 +13,7 @@ import { reserveTarget } from './download-runtime-reservation';
import { reserveFreshCatchupTarget } from './download-catchup-reservation';
import {
clearArchiveFinalization,
recordArchivePartial,
readArchiveFinalizations,
} from './download-catchup-journal';
import type { DownloadsDatabase, DownloadTask } from './download-task';
@@ -21,6 +23,7 @@ jest.mock('./download-catchup-journal', () => ({
clearArchiveFinalization: jest.fn().mockResolvedValue(undefined),
readArchiveFinalizations: jest.fn(),
recordArchiveCleanupPath: jest.fn(),
recordArchivePartial: jest.fn().mockResolvedValue(undefined),
}));
it.each([false, true])(
@@ -83,7 +86,13 @@ it.each([false, true])(
await expect(lstat(filePath + '.part')).rejects.toMatchObject({
code: 'ENOENT',
});
expect(task.catchupExpectedPartialIdentity).toBeUndefined();
expect(
await lstat(join(directory, 'show (1).ts.part'))
).toEqual(
expect.objectContaining(
task.catchupExpectedPartialIdentity!
)
);
}
expect(await readFile(filePath, 'utf8')).toBe('unrelated final');
} finally {
@@ -131,3 +140,43 @@ it('removes a journaled incomplete final before retrying the retained archive',
await rm(directory, { recursive: true, force: true });
}
});
it('binds a fresh reservation before a replacement can arrive during the HTTP wait', async () => {
const directory = await mkdtemp(join(tmpdir(), 'archive-fresh-owner-'));
const task: DownloadTask = {
id: 19,
directory,
fileName: 'show.ts',
url: 'https://provider.test/archive.ts',
catchup: {
channelName: 'News',
startTimestamp: 100,
stopTimestamp: 200,
},
};
try {
const reservation = await reserveTarget({} as DownloadsDatabase, task);
expect(recordArchivePartial).toHaveBeenCalledWith(
expect.anything(),
task.id,
reservation.path,
task.catchupExpectedPartialIdentity
);
await rename(reservation.partialPath, join(directory, 'original'));
await writeFile(
reservation.partialPath,
'foreign file created before the response'
);
await expect(
openCatchupOutput(
reservation.partialPath,
task.catchupExpectedPartialIdentity
)
).rejects.toThrow('changed');
expect(await readFile(reservation.partialPath, 'utf8')).toBe(
'foreign file created before the response'
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -1,8 +1,13 @@
import { closeSync, fstatSync, openSync } from 'node:fs';
import { lstat } from 'node:fs/promises';
import { cleanupStoredCatchupPartial } from './download-catchup-removal';
import { clearArchiveFinalization } from './download-catchup-journal';
import {
clearArchiveFinalization,
recordArchivePartial,
} from './download-catchup-journal';
import {
ArchivePartialReplacedError,
archiveFileIdentity,
sameArchiveFileIdentity,
} from './download-catchup-output';
import { reserveAvailablePartialDownloadFile } from './download-file-path';
@@ -34,6 +39,34 @@ export async function reserveFreshCatchupTarget(
}
}
await clearArchiveFinalization(db, task.id);
task.catchupExpectedPartialIdentity = undefined;
return reserveAvailablePartialDownloadFile(task.directory, task.fileName);
return reserveOwnedCatchupTarget(db, task);
}
/** Capture ownership from the exclusive creation descriptor, before any network wait. */
export async function reserveOwnedCatchupTarget(
db: DownloadsDatabase,
task: DownloadTask
) {
task.catchupExpectedPartialIdentity = undefined;
const reservation = reserveAvailablePartialDownloadFile(
task.directory,
task.fileName,
(partialPath) => {
const descriptor = openSync(partialPath, 'wx', 0o600);
try {
task.catchupExpectedPartialIdentity = archiveFileIdentity(
fstatSync(descriptor)
);
} finally {
closeSync(descriptor);
}
}
);
task.filePath = reservation.path;
task.fileName = reservation.filename;
const identity = task.catchupExpectedPartialIdentity;
if (!identity)
throw new Error('Archive reservation identity is unavailable');
await recordArchivePartial(db, task.id, reservation.path, identity);
return reservation;
}
@@ -49,6 +49,16 @@ function mockRememberCapture(
}
jest.mock('./download-catchup-journal', () => ({
...jest.requireActual('./download-catchup-journal'),
recordArchivePartial: jest.fn(
async (_db, id, filePath, partialIdentity) => {
mockProofs.set(id, {
version: 1,
phase: 'transfer',
filePath,
partialIdentity,
});
}
),
recordArchiveFinalization: jest.fn(async (_db, id, proof) => {
mockProofs.set(id, proof);
}),
@@ -123,6 +123,7 @@ describe('TS archive transfer', () => {
};
try {
await writeFile(path + '.part', 'old data');
task.catchupExpectedPartialIdentity = await stat(path + '.part');
jest.mocked(recordArchivePartial).mockImplementationOnce(
async (_db, id, filePath, identity) => {
expect(filePath).toBe(path);
@@ -5,7 +5,10 @@ import {
verifiedArchiveSize,
} from './download-catchup-journal';
import { cleanupStoredCatchupFinal } from './download-catchup-removal';
import { reserveFreshCatchupTarget } from './download-catchup-reservation';
import {
reserveFreshCatchupTarget,
reserveOwnedCatchupTarget,
} from './download-catchup-reservation';
import {
findAvailableFinalPath,
getPartialDownloadPath,
@@ -62,5 +65,7 @@ export async function reserveTarget(
};
}
return reserveAvailablePartialDownloadFile(task.directory, task.fileName);
return task.catchup
? reserveOwnedCatchupTarget(db, task)
: reserveAvailablePartialDownloadFile(task.directory, task.fileName);
}
+4 -1
View File
@@ -102,7 +102,10 @@ re-download and repeated programme submissions use journal-backed private
capture for archive partial cleanup;
unknown or replaced entries are preserved. Ownership includes a positive file
creation timestamp as well as device/inode, so inode reuse after unlink cannot
bless a new entry; proofs lacking creation time remain untrusted. Before capture, a synchronous SQLite
bless a new entry; proofs lacking creation time remain untrusted. Fresh
reservations capture this identity from their exclusive creation descriptor and
journal it before the HTTP wait. Existing partials without matching expected
ownership are never truncated, including before the first response. Before capture, a synchronous SQLite
write records `partialCleanupPath` (or `finalCleanupPath` for failed promotion)
in the existing ownership proof. Active cancellation/failure, promotion and
startup recovery use the same journal-backed cleanup as manual actions. Cancel,