Skip to content

Commit 95bbee0

Browse files
committed
fix(publication): authenticate stable checkpoint retirement
1 parent fd275d8 commit 95bbee0

3 files changed

Lines changed: 1590 additions & 146 deletions

File tree

scripts/lib/pylon-bounded-file.mjs

Lines changed: 162 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,6 @@ export const PYLON_PUBLICATION_MANIFEST_MAX_BYTES = 64 * 1024;
1212
export const PYLON_STABLE_HISTORY_MAX_MANIFESTS = 4096;
1313
export const PYLON_STABLE_HISTORY_MAX_BYTES = 32 * 1024 * 1024;
1414

15-
export class BoundedFileUnlinkedDuringReadError extends Error {
16-
constructor(path, description, bytes, expectedSha256) {
17-
super(`${description} changed while it was read because the same opened inode was removed.`);
18-
this.name = "BoundedFileUnlinkedDuringReadError";
19-
this.path = path;
20-
this.description = description;
21-
this.bytes = Buffer.from(bytes);
22-
this.expectedSha256 = expectedSha256;
23-
}
24-
}
25-
2615
function statEvidence(stat) {
2716
return Object.freeze({
2817
dev: stat.dev,
@@ -34,6 +23,24 @@ function statEvidence(stat) {
3423
});
3524
}
3625

26+
export class BoundedFileUnlinkedDuringReadError extends Error {
27+
constructor(path, description, bytes, expectedSha256, pathEntry, before, after, confirmedHandle = null) {
28+
super(`${description} changed while it was read because the same opened inode was removed.`);
29+
this.name = "BoundedFileUnlinkedDuringReadError";
30+
this.path = path;
31+
this.description = description;
32+
this.bytes = Buffer.from(bytes);
33+
this.expectedSha256 = expectedSha256;
34+
this.sha256 = createHash("sha256").update(this.bytes).digest("hex");
35+
this.statTransition = Object.freeze({
36+
pathEntry: statEvidence(pathEntry),
37+
before: statEvidence(before),
38+
after: statEvidence(after),
39+
confirmedHandle: confirmedHandle === null ? null : statEvidence(confirmedHandle),
40+
});
41+
}
42+
}
43+
3744
export class BoundedFileLinkRetiredBeforeReadError extends Error {
3845
constructor(path, description, bytes, expectedSha256, pathEntry, openedHandle) {
3946
super(`${description} changed while it was read because one publication hardlink was retired before the file was opened.`);
@@ -50,6 +57,24 @@ export class BoundedFileLinkRetiredBeforeReadError extends Error {
5057
}
5158
}
5259

60+
export class BoundedFileLinkRetiredDuringReadError extends Error {
61+
constructor(path, description, bytes, expectedSha256, pathEntry, before, after, finalPathEntry) {
62+
super(`${description} changed while it was read because one publication hardlink was retired during the bounded read.`);
63+
this.name = "BoundedFileLinkRetiredDuringReadError";
64+
this.path = path;
65+
this.description = description;
66+
this.bytes = Buffer.from(bytes);
67+
this.expectedSha256 = expectedSha256;
68+
this.sha256 = createHash("sha256").update(this.bytes).digest("hex");
69+
this.statTransition = Object.freeze({
70+
pathEntry: statEvidence(pathEntry),
71+
before: statEvidence(before),
72+
after: statEvidence(after),
73+
finalPathEntry: statEvidence(finalPathEntry),
74+
});
75+
}
76+
}
77+
5378
function sameInodeReadBounds(left, right) {
5479
return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
5580
left.mtimeMs === right.mtimeMs;
@@ -59,28 +84,40 @@ function sameStat(left, right) {
5984
return sameInodeReadBounds(left, right) && left.ctimeMs === right.ctimeMs && left.nlink === right.nlink;
6085
}
6186

62-
function isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing) {
63-
return finalPathMissing && extraBytes === 0 && sameStat(pathEntry, before) &&
64-
sameInodeReadBounds(before, after) && before.nlink > 0 && after.nlink === 0;
65-
}
66-
67-
function initialRetirementKind(pathEntry, openedHandle, expectedSha256) {
87+
function exactMonotoneStatCut(observations, fromLinks, toLinks) {
6888
if (
69-
expectedSha256 === null || !sameInodeReadBounds(pathEntry, openedHandle) ||
70-
pathEntry.ctimeMs === openedHandle.ctimeMs
89+
observations.length < 2 || observations[0].nlink !== fromLinks ||
90+
observations.at(-1).nlink !== toLinks ||
91+
observations.some((stat) => !sameInodeReadBounds(observations[0], stat))
7192
) return null;
72-
if (pathEntry.nlink === 2 && openedHandle.nlink === 1) return "link-retired";
73-
if (pathEntry.nlink === 1 && openedHandle.nlink === 0) return "unlinked";
74-
return null;
93+
let cut = null;
94+
for (let index = 1; index < observations.length; index += 1) {
95+
const previous = observations[index - 1];
96+
const current = observations[index];
97+
if (previous.nlink === current.nlink) {
98+
if (previous.ctimeMs !== current.ctimeMs) return null;
99+
continue;
100+
}
101+
if (
102+
cut !== null || previous.nlink !== fromLinks || current.nlink !== toLinks ||
103+
previous.ctimeMs === current.ctimeMs
104+
) return null;
105+
cut = index;
106+
}
107+
return cut;
75108
}
76109

77-
function isStableLinkRetirementBeforeRead(before, after, extraBytes, finalPathEntry, finalPathMissing) {
78-
return !finalPathMissing && extraBytes === 0 && !finalPathEntry.isSymbolicLink?.() && finalPathEntry.isFile() &&
79-
sameStat(before, after) && sameStat(after, finalPathEntry);
110+
function permitsInitialStatTransition(pathEntry, before, expectedSha256) {
111+
return sameStat(pathEntry, before) || (
112+
expectedSha256 !== null && (
113+
exactMonotoneStatCut([pathEntry, before], 2, 1) === 1 ||
114+
exactMonotoneStatCut([pathEntry, before], 1, 0) === 1
115+
)
116+
);
80117
}
81118

82-
function isStableHandleRemovalBeforeRead(before, after, extraBytes, finalPathMissing) {
83-
return finalPathMissing && extraBytes === 0 && sameStat(before, after);
119+
function isRegularPathEntry(pathEntry) {
120+
return !pathEntry.isSymbolicLink?.() && pathEntry.isFile();
84121
}
85122

86123
function exactSha256(bytes, expectedSha256) {
@@ -130,10 +167,8 @@ export async function readBoundedRegularFile(
130167
let before = await handle.stat();
131168
if (!before.isFile()) throw new Error(`${description} is not one regular non-symlink file.`);
132169
if (validateHandle) before = await validateHandle(handle, before, description);
133-
let retirementBeforeRead = null;
134-
if (!sameStat(pathEntry, before)) {
135-
retirementBeforeRead = initialRetirementKind(pathEntry, before, expectedSha256);
136-
if (retirementBeforeRead === null) throw new Error(`${description} changed while it was read.`);
170+
if (!permitsInitialStatTransition(pathEntry, before, expectedSha256)) {
171+
throw new Error(`${description} changed while it was read.`);
137172
}
138173
if (before.size < minBytes || before.size > maxBytes) throw new Error(`${description} exceeds its format byte limit or is malformed.`);
139174
await hooks?.afterInitialStat?.({ path, handle, stat: before });
@@ -148,6 +183,7 @@ export async function readBoundedRegularFile(
148183
const { bytesRead: extraBytes } = await handle.read(extra, 0, 1, bytes.length);
149184
await hooks?.beforeFinalStat?.({ path, handle, bytes });
150185
const after = await handle.stat();
186+
await hooks?.afterFinalStat?.({ path, handle, stat: after, bytes });
151187
let finalPathEntry;
152188
let finalPathMissing = false;
153189
try {
@@ -156,37 +192,56 @@ export async function readBoundedRegularFile(
156192
if (error?.code !== "ENOENT") throw error;
157193
finalPathMissing = true;
158194
}
159-
if (exactSha256(bytes, expectedSha256)) {
160-
if (
161-
retirementBeforeRead === "link-retired" &&
162-
isStableLinkRetirementBeforeRead(before, after, extraBytes, finalPathEntry, finalPathMissing)
163-
) {
164-
throw new BoundedFileLinkRetiredBeforeReadError(
165-
path,
166-
description,
167-
bytes,
168-
expectedSha256,
169-
pathEntry,
170-
before,
171-
);
172-
}
173-
if (
174-
retirementBeforeRead === "unlinked" &&
175-
isStableHandleRemovalBeforeRead(before, after, extraBytes, finalPathMissing)
176-
) {
177-
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
195+
let confirmedHandle = null;
196+
if (finalPathMissing && after.nlink === 1 && sameStat(pathEntry, before) && sameStat(before, after)) {
197+
confirmedHandle = await handle.stat();
198+
}
199+
if (extraBytes === 0 && exactSha256(bytes, expectedSha256)) {
200+
if (!finalPathMissing && isRegularPathEntry(finalPathEntry)) {
201+
const retirementCut = exactMonotoneStatCut([pathEntry, before, after, finalPathEntry], 2, 1);
202+
if (retirementCut === 1) {
203+
throw new BoundedFileLinkRetiredBeforeReadError(
204+
path,
205+
description,
206+
bytes,
207+
expectedSha256,
208+
pathEntry,
209+
before,
210+
);
211+
}
212+
if (retirementCut !== null) {
213+
throw new BoundedFileLinkRetiredDuringReadError(
214+
path,
215+
description,
216+
bytes,
217+
expectedSha256,
218+
pathEntry,
219+
before,
220+
after,
221+
finalPathEntry,
222+
);
223+
}
178224
}
179-
if (
180-
retirementBeforeRead === null &&
181-
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing)
182-
) {
183-
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
225+
if (finalPathMissing) {
226+
const unlinkStats = [pathEntry, before, after];
227+
if (confirmedHandle !== null) unlinkStats.push(confirmedHandle);
228+
if (exactMonotoneStatCut(unlinkStats, 1, 0) !== null) {
229+
throw new BoundedFileUnlinkedDuringReadError(
230+
path,
231+
description,
232+
bytes,
233+
expectedSha256,
234+
pathEntry,
235+
before,
236+
after,
237+
confirmedHandle,
238+
);
239+
}
184240
}
185241
}
186242
if (
187-
retirementBeforeRead !== null || extraBytes !== 0 || finalPathMissing ||
188-
finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
189-
!sameStat(before, after) || !sameStat(after, finalPathEntry)
243+
extraBytes !== 0 || finalPathMissing || !isRegularPathEntry(finalPathEntry) ||
244+
!sameStat(pathEntry, before) || !sameStat(before, after) || !sameStat(after, finalPathEntry)
190245
) throw new Error(`${description} changed while it was read.`);
191246
return bytes;
192247
} finally {
@@ -237,10 +292,8 @@ export function readBoundedRegularFileSync(
237292
try {
238293
const before = statFile(descriptor);
239294
if (!before.isFile()) throw new Error(`${description} is not one regular non-symlink file.`);
240-
let retirementBeforeRead = null;
241-
if (!sameStat(pathEntry, before)) {
242-
retirementBeforeRead = initialRetirementKind(pathEntry, before, expectedSha256);
243-
if (retirementBeforeRead === null) throw new Error(`${description} changed while it was read.`);
295+
if (!permitsInitialStatTransition(pathEntry, before, expectedSha256)) {
296+
throw new Error(`${description} changed while it was read.`);
244297
}
245298
if (before.size < minBytes || before.size > maxBytes) throw new Error(`${description} exceeds its format byte limit or is malformed.`);
246299
hooks?.afterInitialStat?.({ path, descriptor, stat: before });
@@ -255,6 +308,7 @@ export function readBoundedRegularFileSync(
255308
const extraBytes = readFile(descriptor, extra, 0, 1, bytes.length);
256309
hooks?.beforeFinalStat?.({ path, descriptor, bytes });
257310
const after = statFile(descriptor);
311+
hooks?.afterFinalStat?.({ path, descriptor, stat: after, bytes });
258312
let finalPathEntry;
259313
let finalPathMissing = false;
260314
try {
@@ -263,37 +317,56 @@ export function readBoundedRegularFileSync(
263317
if (error?.code !== "ENOENT") throw error;
264318
finalPathMissing = true;
265319
}
266-
if (exactSha256(bytes, expectedSha256)) {
267-
if (
268-
retirementBeforeRead === "link-retired" &&
269-
isStableLinkRetirementBeforeRead(before, after, extraBytes, finalPathEntry, finalPathMissing)
270-
) {
271-
throw new BoundedFileLinkRetiredBeforeReadError(
272-
path,
273-
description,
274-
bytes,
275-
expectedSha256,
276-
pathEntry,
277-
before,
278-
);
279-
}
280-
if (
281-
retirementBeforeRead === "unlinked" &&
282-
isStableHandleRemovalBeforeRead(before, after, extraBytes, finalPathMissing)
283-
) {
284-
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
320+
let confirmedHandle = null;
321+
if (finalPathMissing && after.nlink === 1 && sameStat(pathEntry, before) && sameStat(before, after)) {
322+
confirmedHandle = statFile(descriptor);
323+
}
324+
if (extraBytes === 0 && exactSha256(bytes, expectedSha256)) {
325+
if (!finalPathMissing && isRegularPathEntry(finalPathEntry)) {
326+
const retirementCut = exactMonotoneStatCut([pathEntry, before, after, finalPathEntry], 2, 1);
327+
if (retirementCut === 1) {
328+
throw new BoundedFileLinkRetiredBeforeReadError(
329+
path,
330+
description,
331+
bytes,
332+
expectedSha256,
333+
pathEntry,
334+
before,
335+
);
336+
}
337+
if (retirementCut !== null) {
338+
throw new BoundedFileLinkRetiredDuringReadError(
339+
path,
340+
description,
341+
bytes,
342+
expectedSha256,
343+
pathEntry,
344+
before,
345+
after,
346+
finalPathEntry,
347+
);
348+
}
285349
}
286-
if (
287-
retirementBeforeRead === null &&
288-
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing)
289-
) {
290-
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
350+
if (finalPathMissing) {
351+
const unlinkStats = [pathEntry, before, after];
352+
if (confirmedHandle !== null) unlinkStats.push(confirmedHandle);
353+
if (exactMonotoneStatCut(unlinkStats, 1, 0) !== null) {
354+
throw new BoundedFileUnlinkedDuringReadError(
355+
path,
356+
description,
357+
bytes,
358+
expectedSha256,
359+
pathEntry,
360+
before,
361+
after,
362+
confirmedHandle,
363+
);
364+
}
291365
}
292366
}
293367
if (
294-
retirementBeforeRead !== null || extraBytes !== 0 || finalPathMissing ||
295-
finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
296-
!sameStat(before, after) || !sameStat(after, finalPathEntry)
368+
extraBytes !== 0 || finalPathMissing || !isRegularPathEntry(finalPathEntry) ||
369+
!sameStat(pathEntry, before) || !sameStat(before, after) || !sameStat(after, finalPathEntry)
297370
) throw new Error(`${description} changed while it was read.`);
298371
return bytes;
299372
} finally {

0 commit comments

Comments
 (0)