Skip to content

Commit ca56967

Browse files
authored
Merge pull request #48 from pylon-code/fix/publication-claim-read-convergence
fix(publication): authenticate rotation handoffs
2 parents 6a4cd07 + 0dcbe78 commit ca56967

3 files changed

Lines changed: 1163 additions & 81 deletions

File tree

scripts/lib/pylon-bounded-file.mjs

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import {
23
closeSync,
34
constants,
@@ -11,10 +12,33 @@ export const PYLON_PUBLICATION_MANIFEST_MAX_BYTES = 64 * 1024;
1112
export const PYLON_STABLE_HISTORY_MAX_MANIFESTS = 4096;
1213
export const PYLON_STABLE_HISTORY_MAX_BYTES = 32 * 1024 * 1024;
1314

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+
}
1425

15-
function sameStat(left, right) {
26+
function sameInodeReadBounds(left, right) {
1627
return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
17-
left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
28+
left.mtimeMs === right.mtimeMs;
29+
}
30+
31+
function sameStat(left, right) {
32+
return sameInodeReadBounds(left, right) && left.ctimeMs === right.ctimeMs && left.nlink === right.nlink;
33+
}
34+
35+
function isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing) {
36+
return finalPathMissing && extraBytes === 0 && sameStat(pathEntry, before) &&
37+
sameInodeReadBounds(before, after) && before.nlink > 0 && after.nlink === 0;
38+
}
39+
40+
function exactSha256(bytes, expectedSha256) {
41+
return expectedSha256 !== null && createHash("sha256").update(bytes).digest("hex") === expectedSha256;
1842
}
1943

2044
export async function readBoundedRegularFile(
@@ -27,9 +51,13 @@ export async function readBoundedRegularFile(
2751
lstatEntry = lstat,
2852
validateHandle,
2953
hooks,
54+
expectedSha256 = null,
3055
} = {},
3156
) {
32-
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes) {
57+
if (
58+
!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes ||
59+
!(expectedSha256 === null || /^[0-9a-f]{64}$/.test(expectedSha256))
60+
) {
3361
throw new Error("Bounded file limits are invalid.");
3462
}
3563
let pathEntry;
@@ -56,6 +84,7 @@ export async function readBoundedRegularFile(
5684
let before = await handle.stat();
5785
if (!before.isFile()) throw new Error(`${description} is not one regular non-symlink file.`);
5886
if (validateHandle) before = await validateHandle(handle, before, description);
87+
if (!sameStat(pathEntry, before)) throw new Error(`${description} changed while it was read.`);
5988
if (before.size < minBytes || before.size > maxBytes) throw new Error(`${description} exceeds its format byte limit or is malformed.`);
6089
await hooks?.afterInitialStat?.({ path, handle, stat: before });
6190
const bytes = Buffer.alloc(before.size);
@@ -69,7 +98,24 @@ export async function readBoundedRegularFile(
6998
const { bytesRead: extraBytes } = await handle.read(extra, 0, 1, bytes.length);
7099
await hooks?.beforeFinalStat?.({ path, handle, bytes });
71100
const after = await handle.stat();
72-
if (extraBytes !== 0 || !sameStat(before, after)) throw new Error(`${description} changed while it was read.`);
101+
let finalPathEntry;
102+
let finalPathMissing = false;
103+
try {
104+
finalPathEntry = await lstatEntry(path);
105+
} catch (error) {
106+
if (error?.code !== "ENOENT") throw error;
107+
finalPathMissing = true;
108+
}
109+
if (
110+
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing) &&
111+
exactSha256(bytes, expectedSha256)
112+
) {
113+
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
114+
}
115+
if (
116+
extraBytes !== 0 || finalPathMissing || finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
117+
!sameStat(before, after) || !sameStat(after, finalPathEntry)
118+
) throw new Error(`${description} changed while it was read.`);
73119
return bytes;
74120
} finally {
75121
await handle.close();
@@ -89,9 +135,13 @@ export function readBoundedRegularFileSync(
89135
readFile = readSync,
90136
closeFile = closeSync,
91137
hooks,
138+
expectedSha256 = null,
92139
} = {},
93140
) {
94-
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes) {
141+
if (
142+
!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes ||
143+
!(expectedSha256 === null || /^[0-9a-f]{64}$/.test(expectedSha256))
144+
) {
95145
throw new Error("Bounded file limits are invalid.");
96146
}
97147
let pathEntry;
@@ -115,6 +165,7 @@ export function readBoundedRegularFileSync(
115165
try {
116166
const before = statFile(descriptor);
117167
if (!before.isFile()) throw new Error(`${description} is not one regular non-symlink file.`);
168+
if (!sameStat(pathEntry, before)) throw new Error(`${description} changed while it was read.`);
118169
if (before.size < minBytes || before.size > maxBytes) throw new Error(`${description} exceeds its format byte limit or is malformed.`);
119170
hooks?.afterInitialStat?.({ path, descriptor, stat: before });
120171
const bytes = Buffer.alloc(before.size);
@@ -128,7 +179,24 @@ export function readBoundedRegularFileSync(
128179
const extraBytes = readFile(descriptor, extra, 0, 1, bytes.length);
129180
hooks?.beforeFinalStat?.({ path, descriptor, bytes });
130181
const after = statFile(descriptor);
131-
if (extraBytes !== 0 || !sameStat(before, after)) throw new Error(`${description} changed while it was read.`);
182+
let finalPathEntry;
183+
let finalPathMissing = false;
184+
try {
185+
finalPathEntry = lstatEntry(path);
186+
} catch (error) {
187+
if (error?.code !== "ENOENT") throw error;
188+
finalPathMissing = true;
189+
}
190+
if (
191+
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing) &&
192+
exactSha256(bytes, expectedSha256)
193+
) {
194+
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
195+
}
196+
if (
197+
extraBytes !== 0 || finalPathMissing || finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
198+
!sameStat(before, after) || !sameStat(after, finalPathEntry)
199+
) throw new Error(`${description} changed while it was read.`);
132200
return bytes;
133201
} finally {
134202
closeFile(descriptor);

0 commit comments

Comments
 (0)