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
251 changes: 162 additions & 89 deletions scripts/lib/pylon-bounded-file.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,6 @@ 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 statEvidence(stat) {
return Object.freeze({
dev: stat.dev,
Expand All @@ -34,6 +23,24 @@ function statEvidence(stat) {
});
}

export class BoundedFileUnlinkedDuringReadError extends Error {
constructor(path, description, bytes, expectedSha256, pathEntry, before, after, confirmedHandle = null) {
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;
this.sha256 = createHash("sha256").update(this.bytes).digest("hex");
this.statTransition = Object.freeze({
pathEntry: statEvidence(pathEntry),
before: statEvidence(before),
after: statEvidence(after),
confirmedHandle: confirmedHandle === null ? null : statEvidence(confirmedHandle),
});
}
}

export class BoundedFileLinkRetiredBeforeReadError extends Error {
constructor(path, description, bytes, expectedSha256, pathEntry, openedHandle) {
super(`${description} changed while it was read because one publication hardlink was retired before the file was opened.`);
Expand All @@ -50,6 +57,24 @@ export class BoundedFileLinkRetiredBeforeReadError extends Error {
}
}

export class BoundedFileLinkRetiredDuringReadError extends Error {
constructor(path, description, bytes, expectedSha256, pathEntry, before, after, finalPathEntry) {
super(`${description} changed while it was read because one publication hardlink was retired during the bounded read.`);
this.name = "BoundedFileLinkRetiredDuringReadError";
this.path = path;
this.description = description;
this.bytes = Buffer.from(bytes);
this.expectedSha256 = expectedSha256;
this.sha256 = createHash("sha256").update(this.bytes).digest("hex");
this.statTransition = Object.freeze({
pathEntry: statEvidence(pathEntry),
before: statEvidence(before),
after: statEvidence(after),
finalPathEntry: statEvidence(finalPathEntry),
});
}
}

function sameInodeReadBounds(left, right) {
return left.dev === right.dev && left.ino === right.ino && left.size === right.size &&
left.mtimeMs === right.mtimeMs;
Expand All @@ -59,28 +84,40 @@ 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 initialRetirementKind(pathEntry, openedHandle, expectedSha256) {
function exactMonotoneStatCut(observations, fromLinks, toLinks) {
if (
expectedSha256 === null || !sameInodeReadBounds(pathEntry, openedHandle) ||
pathEntry.ctimeMs === openedHandle.ctimeMs
observations.length < 2 || observations[0].nlink !== fromLinks ||
observations.at(-1).nlink !== toLinks ||
observations.some((stat) => !sameInodeReadBounds(observations[0], stat))
) return null;
if (pathEntry.nlink === 2 && openedHandle.nlink === 1) return "link-retired";
if (pathEntry.nlink === 1 && openedHandle.nlink === 0) return "unlinked";
return null;
let cut = null;
for (let index = 1; index < observations.length; index += 1) {
const previous = observations[index - 1];
const current = observations[index];
if (previous.nlink === current.nlink) {
if (previous.ctimeMs !== current.ctimeMs) return null;
continue;
}
if (
cut !== null || previous.nlink !== fromLinks || current.nlink !== toLinks ||
previous.ctimeMs === current.ctimeMs
) return null;
cut = index;
}
return cut;
}

function isStableLinkRetirementBeforeRead(before, after, extraBytes, finalPathEntry, finalPathMissing) {
return !finalPathMissing && extraBytes === 0 && !finalPathEntry.isSymbolicLink?.() && finalPathEntry.isFile() &&
sameStat(before, after) && sameStat(after, finalPathEntry);
function permitsInitialStatTransition(pathEntry, before, expectedSha256) {
return sameStat(pathEntry, before) || (
expectedSha256 !== null && (
exactMonotoneStatCut([pathEntry, before], 2, 1) === 1 ||
exactMonotoneStatCut([pathEntry, before], 1, 0) === 1
)
);
}

function isStableHandleRemovalBeforeRead(before, after, extraBytes, finalPathMissing) {
return finalPathMissing && extraBytes === 0 && sameStat(before, after);
function isRegularPathEntry(pathEntry) {
return !pathEntry.isSymbolicLink?.() && pathEntry.isFile();
}

function exactSha256(bytes, expectedSha256) {
Expand Down Expand Up @@ -130,10 +167,8 @@ 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);
let retirementBeforeRead = null;
if (!sameStat(pathEntry, before)) {
retirementBeforeRead = initialRetirementKind(pathEntry, before, expectedSha256);
if (retirementBeforeRead === null) throw new Error(`${description} changed while it was read.`);
if (!permitsInitialStatTransition(pathEntry, before, expectedSha256)) {
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 });
Expand All @@ -148,6 +183,7 @@ 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();
await hooks?.afterFinalStat?.({ path, handle, stat: after, bytes });
let finalPathEntry;
let finalPathMissing = false;
try {
Expand All @@ -156,37 +192,56 @@ export async function readBoundedRegularFile(
if (error?.code !== "ENOENT") throw error;
finalPathMissing = true;
}
if (exactSha256(bytes, expectedSha256)) {
if (
retirementBeforeRead === "link-retired" &&
isStableLinkRetirementBeforeRead(before, after, extraBytes, finalPathEntry, finalPathMissing)
) {
throw new BoundedFileLinkRetiredBeforeReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
);
}
if (
retirementBeforeRead === "unlinked" &&
isStableHandleRemovalBeforeRead(before, after, extraBytes, finalPathMissing)
) {
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
let confirmedHandle = null;
if (finalPathMissing && after.nlink === 1 && sameStat(pathEntry, before) && sameStat(before, after)) {
confirmedHandle = await handle.stat();
}
if (extraBytes === 0 && exactSha256(bytes, expectedSha256)) {
if (!finalPathMissing && isRegularPathEntry(finalPathEntry)) {
const retirementCut = exactMonotoneStatCut([pathEntry, before, after, finalPathEntry], 2, 1);
if (retirementCut === 1) {
throw new BoundedFileLinkRetiredBeforeReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
);
}
if (retirementCut !== null) {
throw new BoundedFileLinkRetiredDuringReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
after,
finalPathEntry,
);
}
}
if (
retirementBeforeRead === null &&
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing)
) {
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
if (finalPathMissing) {
const unlinkStats = [pathEntry, before, after];
if (confirmedHandle !== null) unlinkStats.push(confirmedHandle);
if (exactMonotoneStatCut(unlinkStats, 1, 0) !== null) {
throw new BoundedFileUnlinkedDuringReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
after,
confirmedHandle,
);
}
}
}
if (
retirementBeforeRead !== null || extraBytes !== 0 || finalPathMissing ||
finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
!sameStat(before, after) || !sameStat(after, finalPathEntry)
extraBytes !== 0 || finalPathMissing || !isRegularPathEntry(finalPathEntry) ||
!sameStat(pathEntry, before) || !sameStat(before, after) || !sameStat(after, finalPathEntry)
) throw new Error(`${description} changed while it was read.`);
return bytes;
} finally {
Expand Down Expand Up @@ -237,10 +292,8 @@ export function readBoundedRegularFileSync(
try {
const before = statFile(descriptor);
if (!before.isFile()) throw new Error(`${description} is not one regular non-symlink file.`);
let retirementBeforeRead = null;
if (!sameStat(pathEntry, before)) {
retirementBeforeRead = initialRetirementKind(pathEntry, before, expectedSha256);
if (retirementBeforeRead === null) throw new Error(`${description} changed while it was read.`);
if (!permitsInitialStatTransition(pathEntry, before, expectedSha256)) {
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 });
Expand All @@ -255,6 +308,7 @@ export function readBoundedRegularFileSync(
const extraBytes = readFile(descriptor, extra, 0, 1, bytes.length);
hooks?.beforeFinalStat?.({ path, descriptor, bytes });
const after = statFile(descriptor);
hooks?.afterFinalStat?.({ path, descriptor, stat: after, bytes });
let finalPathEntry;
let finalPathMissing = false;
try {
Expand All @@ -263,37 +317,56 @@ export function readBoundedRegularFileSync(
if (error?.code !== "ENOENT") throw error;
finalPathMissing = true;
}
if (exactSha256(bytes, expectedSha256)) {
if (
retirementBeforeRead === "link-retired" &&
isStableLinkRetirementBeforeRead(before, after, extraBytes, finalPathEntry, finalPathMissing)
) {
throw new BoundedFileLinkRetiredBeforeReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
);
}
if (
retirementBeforeRead === "unlinked" &&
isStableHandleRemovalBeforeRead(before, after, extraBytes, finalPathMissing)
) {
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
let confirmedHandle = null;
if (finalPathMissing && after.nlink === 1 && sameStat(pathEntry, before) && sameStat(before, after)) {
confirmedHandle = statFile(descriptor);
}
if (extraBytes === 0 && exactSha256(bytes, expectedSha256)) {
if (!finalPathMissing && isRegularPathEntry(finalPathEntry)) {
const retirementCut = exactMonotoneStatCut([pathEntry, before, after, finalPathEntry], 2, 1);
if (retirementCut === 1) {
throw new BoundedFileLinkRetiredBeforeReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
);
}
if (retirementCut !== null) {
throw new BoundedFileLinkRetiredDuringReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
after,
finalPathEntry,
);
}
}
if (
retirementBeforeRead === null &&
isPinnedHandleRemoval(pathEntry, before, after, extraBytes, finalPathMissing)
) {
throw new BoundedFileUnlinkedDuringReadError(path, description, bytes, expectedSha256);
if (finalPathMissing) {
const unlinkStats = [pathEntry, before, after];
if (confirmedHandle !== null) unlinkStats.push(confirmedHandle);
if (exactMonotoneStatCut(unlinkStats, 1, 0) !== null) {
throw new BoundedFileUnlinkedDuringReadError(
path,
description,
bytes,
expectedSha256,
pathEntry,
before,
after,
confirmedHandle,
);
}
}
}
if (
retirementBeforeRead !== null || extraBytes !== 0 || finalPathMissing ||
finalPathEntry.isSymbolicLink?.() || !finalPathEntry.isFile() ||
!sameStat(before, after) || !sameStat(after, finalPathEntry)
extraBytes !== 0 || finalPathMissing || !isRegularPathEntry(finalPathEntry) ||
!sameStat(pathEntry, before) || !sameStat(before, after) || !sameStat(after, finalPathEntry)
) throw new Error(`${description} changed while it was read.`);
return bytes;
} finally {
Expand Down
Loading
Loading