Skip to content
Merged
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
80 changes: 74 additions & 6 deletions scripts/lib/pylon-bounded-file.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import {
closeSync,
constants,
Expand All @@ -11,10 +12,33 @@ export const PYLON_PUBLICATION_MANIFEST_MAX_BYTES = 64 * 1024;
export const PYLON_STABLE_HISTORY_MAX_MANIFESTS = 4096;
export const PYLON_STABLE_HISTORY_MAX_BYTES = 32 * 1024 * 1024;

export class BoundedFileUnlinkedDuringReadError extends Error {
constructor(path, description, bytes, expectedSha256) {
super(`${description} changed while it was read because the same opened inode was removed.`);
this.name = "BoundedFileUnlinkedDuringReadError";
this.path = path;
this.description = description;
this.bytes = Buffer.from(bytes);
this.expectedSha256 = expectedSha256;
}
}

function sameStat(left, right) {
function sameInodeReadBounds(left, right) {
return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
left.mtimeMs === right.mtimeMs;
}

function sameStat(left, right) {
return sameInodeReadBounds(left, right) && left.ctimeMs === right.ctimeMs && left.nlink === right.nlink;
}

function isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing) {
return finalPathMissing && extraBytes === 0 && sameStat(pathEntry, before) &&
sameInodeReadBounds(before, after) && before.nlink > 0 && after.nlink === 0;
}

function exactSha256(bytes, expectedSha256) {
return expectedSha256 !== null && createHash("sha256").update(bytes).digest("hex") === expectedSha256;
}

export async function readBoundedRegularFile(
Expand All @@ -27,9 +51,13 @@ export async function readBoundedRegularFile(
lstatEntry = lstat,
validateHandle,
hooks,
expectedSha256 = null,
} = {},
) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes) {
if (
!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes ||
!(expectedSha256 === null || /^[0-9a-f]{64}$/.test(expectedSha256))
) {
throw new Error("Bounded file limits are invalid.");
}
let pathEntry;
Expand All @@ -56,6 +84,7 @@ export async function readBoundedRegularFile(
let before = await handle.stat();
if (!before.isFile()) throw new Error(`${description} is not one regular non-symlink file.`);
if (validateHandle) before = await validateHandle(handle, before, description);
if (!sameStat(pathEntry, before)) throw new Error(`${description} changed while it was read.`);
if (before.size < minBytes || before.size > maxBytes) throw new Error(`${description} exceeds its format byte limit or is malformed.`);
await hooks?.afterInitialStat?.({ path, handle, stat: before });
const bytes = Buffer.alloc(before.size);
Expand All @@ -69,7 +98,24 @@ export async function readBoundedRegularFile(
const { bytesRead: extraBytes } = await handle.read(extra, 0, 1, bytes.length);
await hooks?.beforeFinalStat?.({ path, handle, bytes });
const after = await handle.stat();
if (extraBytes !== 0 || !sameStat(before, after)) throw new Error(`${description} changed while it was read.`);
let finalPathEntry;
let finalPathMissing = false;
try {
finalPathEntry = await lstatEntry(path);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
finalPathMissing = true;
}
if (
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing) &&
exactSha256(bytes, expectedSha256)
) {
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
}
if (
extraBytes !== 0 || finalPathMissing || finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
!sameStat(before, after) || !sameStat(after, finalPathEntry)
) throw new Error(`${description} changed while it was read.`);
return bytes;
} finally {
await handle.close();
Expand All @@ -89,9 +135,13 @@ export function readBoundedRegularFileSync(
readFile = readSync,
closeFile = closeSync,
hooks,
expectedSha256 = null,
} = {},
) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes) {
if (
!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(minBytes) || minBytes < 0 || minBytes > maxBytes ||
!(expectedSha256 === null || /^[0-9a-f]{64}$/.test(expectedSha256))
) {
throw new Error("Bounded file limits are invalid.");
}
let pathEntry;
Expand All @@ -115,6 +165,7 @@ export function readBoundedRegularFileSync(
try {
const before = statFile(descriptor);
if (!before.isFile()) throw new Error(`${description} is not one regular non-symlink file.`);
if (!sameStat(pathEntry, before)) throw new Error(`${description} changed while it was read.`);
if (before.size < minBytes || before.size > maxBytes) throw new Error(`${description} exceeds its format byte limit or is malformed.`);
hooks?.afterInitialStat?.({ path, descriptor, stat: before });
const bytes = Buffer.alloc(before.size);
Expand All @@ -128,7 +179,24 @@ export function readBoundedRegularFileSync(
const extraBytes = readFile(descriptor, extra, 0, 1, bytes.length);
hooks?.beforeFinalStat?.({ path, descriptor, bytes });
const after = statFile(descriptor);
if (extraBytes !== 0 || !sameStat(before, after)) throw new Error(`${description} changed while it was read.`);
let finalPathEntry;
let finalPathMissing = false;
try {
finalPathEntry = lstatEntry(path);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
finalPathMissing = true;
}
if (
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing) &&
exactSha256(bytes, expectedSha256)
) {
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
}
if (
extraBytes !== 0 || finalPathMissing || finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
!sameStat(before, after) || !sameStat(after, finalPathEntry)
) throw new Error(`${description} changed while it was read.`);
return bytes;
} finally {
closeFile(descriptor);
Expand Down
Loading
Loading