347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { constants, type Stats } from "node:fs";
|
|
import {
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
realpath,
|
|
rename,
|
|
rm,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts";
|
|
|
|
type WritableHandle = Readonly<{
|
|
writeFile(data: string): Promise<unknown>;
|
|
sync(): Promise<unknown>;
|
|
close(): Promise<unknown>;
|
|
}>;
|
|
|
|
type DirectoryHandle = Readonly<{
|
|
sync(): Promise<unknown>;
|
|
close(): Promise<unknown>;
|
|
}>;
|
|
|
|
export type RiskCoverageArtifactFileSystem = Readonly<{
|
|
openFile(target: string, flags: number, mode: number): Promise<WritableHandle>;
|
|
openDirectory(target: string): Promise<DirectoryHandle>;
|
|
rename(source: string, destination: string): Promise<unknown>;
|
|
rm(target: string, options: Readonly<{ force: true }>): Promise<unknown>;
|
|
}>;
|
|
|
|
type WriterDependencies = Readonly<{
|
|
createNonce?: () => string;
|
|
fileSystem?: RiskCoverageArtifactFileSystem;
|
|
}>;
|
|
|
|
type ReadableHandle = Readonly<{
|
|
stat(): Promise<Stats>;
|
|
readFile(encoding: "utf8"): Promise<string>;
|
|
close(): Promise<unknown>;
|
|
}>;
|
|
|
|
type InputDependencies = Readonly<{
|
|
lstatPath?: (target: string) => Promise<Stats>;
|
|
realpathPath?: (target: string) => Promise<string>;
|
|
openFile?: (target: string, flags: number) => Promise<ReadableHandle>;
|
|
}>;
|
|
|
|
const defaultFileSystem: RiskCoverageArtifactFileSystem = Object.freeze({
|
|
openFile: async (target, flags, mode) => {
|
|
const handle = await open(target, flags, mode);
|
|
return {
|
|
writeFile: async (data) => handle.writeFile(data, "utf8"),
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
openDirectory: async (target) => {
|
|
const handle = await open(target, constants.O_RDONLY);
|
|
return {
|
|
sync: async () => handle.sync(),
|
|
close: async () => handle.close(),
|
|
};
|
|
},
|
|
rename,
|
|
rm,
|
|
});
|
|
|
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
return (
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"code" in error &&
|
|
error.code === code
|
|
);
|
|
}
|
|
|
|
function isWithin(root: string, target: string): boolean {
|
|
const relative = path.relative(root, target);
|
|
return (
|
|
relative === "" ||
|
|
(relative !== ".." &&
|
|
!relative.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relative))
|
|
);
|
|
}
|
|
|
|
function hasStableIdentity(metadata: Stats): boolean {
|
|
return (
|
|
Number.isSafeInteger(metadata.dev) &&
|
|
Number.isSafeInteger(metadata.ino) &&
|
|
metadata.dev > 0 &&
|
|
metadata.ino > 0
|
|
);
|
|
}
|
|
|
|
function sameFileIdentity(before: Stats, after: Stats): boolean {
|
|
if (!hasStableIdentity(before) || !hasStableIdentity(after)) {
|
|
throw new TypeError("stable file identity unavailable");
|
|
}
|
|
return before.dev === after.dev && before.ino === after.ino;
|
|
}
|
|
|
|
async function rejectSymlinkAncestors(
|
|
repositoryRoot: string,
|
|
relativePath: string,
|
|
label: string,
|
|
lstatPath: (target: string) => Promise<Stats>,
|
|
): Promise<void> {
|
|
let current = repositoryRoot;
|
|
let currentRelative = "";
|
|
const directory = path.posix.dirname(relativePath);
|
|
if (directory === ".") return;
|
|
for (const segment of directory.split("/")) {
|
|
current = path.join(current, segment);
|
|
currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment;
|
|
const metadata = await lstatPath(current);
|
|
if (metadata.isSymbolicLink()) {
|
|
throw new TypeError(`${label} ancestor is a symlink: ${currentRelative}`);
|
|
}
|
|
if (!metadata.isDirectory()) {
|
|
throw new TypeError(`${label} ancestor is not a directory: ${currentRelative}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertDirectory(metadata: Stats, relativePath: string): void {
|
|
if (metadata.isSymbolicLink()) {
|
|
throw new TypeError(`artifact ancestor is a symlink: ${relativePath}`);
|
|
}
|
|
if (!metadata.isDirectory()) {
|
|
throw new TypeError(`artifact ancestor is not a directory: ${relativePath}`);
|
|
}
|
|
}
|
|
|
|
export async function readRiskCoverageInput(input: Readonly<{
|
|
repositoryRoot: string;
|
|
relativePath: string;
|
|
label: string;
|
|
}>, dependencies: InputDependencies = {}): Promise<Readonly<{
|
|
relativePath: string;
|
|
absolutePath: string;
|
|
text: string;
|
|
}>> {
|
|
const lstatPath = dependencies.lstatPath ?? lstat;
|
|
const realpathPath = dependencies.realpathPath ?? realpath;
|
|
const openFile = dependencies.openFile ??
|
|
((target: string, flags: number) => open(target, flags));
|
|
const relativePath = normalizeRepositoryRelativePath(
|
|
input.relativePath,
|
|
`${input.label} path`,
|
|
);
|
|
const repositoryRoot = path.resolve(input.repositoryRoot);
|
|
const repositoryRealpath = await realpathPath(repositoryRoot);
|
|
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
|
await rejectSymlinkAncestors(
|
|
repositoryRoot,
|
|
relativePath,
|
|
input.label,
|
|
lstatPath,
|
|
);
|
|
const metadata = await lstatPath(absolutePath);
|
|
if (metadata.isSymbolicLink()) {
|
|
throw new TypeError(`${input.label} path is a symlink: ${relativePath}`);
|
|
}
|
|
if (!metadata.isFile()) {
|
|
throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`);
|
|
}
|
|
const resolvedPath = await realpathPath(absolutePath);
|
|
if (!isWithin(repositoryRealpath, resolvedPath)) {
|
|
throw new TypeError(`${input.label} path is outside repository: ${relativePath}`);
|
|
}
|
|
|
|
const handle = await openFile(
|
|
absolutePath,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
);
|
|
try {
|
|
const openedMetadata = await handle.stat();
|
|
if (!openedMetadata.isFile()) {
|
|
throw new TypeError(`${input.label} path is not a regular file: ${relativePath}`);
|
|
}
|
|
if (!sameFileIdentity(metadata, openedMetadata)) {
|
|
throw new TypeError(`${input.label} path changed during validation: ${relativePath}`);
|
|
}
|
|
const text = await handle.readFile("utf8");
|
|
return Object.freeze({ relativePath, absolutePath, text });
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
export async function resolveRiskCoverageArtifactPath(input: Readonly<{
|
|
repositoryRoot: string;
|
|
relativePath: string;
|
|
inputPaths: readonly string[];
|
|
}>): Promise<string> {
|
|
const relativePath = normalizeRepositoryRelativePath(
|
|
input.relativePath,
|
|
"artifact path",
|
|
);
|
|
if (!relativePath.startsWith("artifacts/quality/")) {
|
|
throw new TypeError("artifact path must be below artifacts/quality");
|
|
}
|
|
const normalizedInputs = input.inputPaths.map((inputPath) =>
|
|
normalizeRepositoryRelativePath(inputPath, "input path"),
|
|
);
|
|
if (normalizedInputs.includes(relativePath)) {
|
|
throw new TypeError(`artifact path must not overwrite an input: ${relativePath}`);
|
|
}
|
|
|
|
const repositoryRoot = path.resolve(input.repositoryRoot);
|
|
const repositoryRealpath = await realpath(repositoryRoot);
|
|
const relativeDirectory = path.posix.dirname(relativePath);
|
|
let currentDirectory = repositoryRoot;
|
|
let currentRelative = "";
|
|
for (const segment of relativeDirectory.split("/")) {
|
|
currentDirectory = path.join(currentDirectory, segment);
|
|
currentRelative = currentRelative ? `${currentRelative}/${segment}` : segment;
|
|
let metadata: Stats;
|
|
try {
|
|
metadata = await lstat(currentDirectory);
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
try {
|
|
await mkdir(currentDirectory);
|
|
} catch (mkdirError) {
|
|
if (!hasErrorCode(mkdirError, "EEXIST")) throw mkdirError;
|
|
}
|
|
metadata = await lstat(currentDirectory);
|
|
}
|
|
assertDirectory(metadata, currentRelative);
|
|
const resolvedDirectory = await realpath(currentDirectory);
|
|
if (!isWithin(repositoryRealpath, resolvedDirectory)) {
|
|
throw new TypeError(`artifact ancestor is outside repository: ${currentRelative}`);
|
|
}
|
|
}
|
|
|
|
const absolutePath = path.resolve(repositoryRoot, relativePath);
|
|
let destinationMetadata: Stats | undefined;
|
|
try {
|
|
destinationMetadata = await lstat(absolutePath);
|
|
if (destinationMetadata.isSymbolicLink()) {
|
|
throw new TypeError(`artifact path is a symlink: ${relativePath}`);
|
|
}
|
|
if (!destinationMetadata.isFile()) {
|
|
throw new TypeError(`artifact path is not a regular file: ${relativePath}`);
|
|
}
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
}
|
|
if (destinationMetadata) {
|
|
if (!hasStableIdentity(destinationMetadata)) {
|
|
throw new TypeError("artifact stable file identity unavailable");
|
|
}
|
|
const destinationRealpath = await realpath(absolutePath);
|
|
for (const inputPath of normalizedInputs) {
|
|
const inputAbsolutePath = path.resolve(repositoryRoot, inputPath);
|
|
const inputRealpath = await realpath(inputAbsolutePath);
|
|
const inputMetadata = await lstat(inputAbsolutePath);
|
|
if (!hasStableIdentity(inputMetadata)) {
|
|
throw new TypeError(`input stable file identity unavailable: ${inputPath}`);
|
|
}
|
|
if (
|
|
inputRealpath === destinationRealpath ||
|
|
(inputMetadata.dev === destinationMetadata.dev &&
|
|
inputMetadata.ino === destinationMetadata.ino)
|
|
) {
|
|
throw new TypeError(`artifact path is the same file as an input: ${inputPath}`);
|
|
}
|
|
}
|
|
}
|
|
return absolutePath;
|
|
}
|
|
|
|
export async function writeRiskCoverageArtifactAtomic(
|
|
input: Readonly<{
|
|
repositoryRoot: string;
|
|
relativePath: string;
|
|
inputPaths: readonly string[];
|
|
value: unknown;
|
|
}>,
|
|
dependencies: WriterDependencies = {},
|
|
): Promise<void> {
|
|
const serialized = JSON.stringify(input.value, null, 2);
|
|
if (serialized === undefined) {
|
|
throw new TypeError("risk coverage artifact is not JSON serializable");
|
|
}
|
|
const destination = await resolveRiskCoverageArtifactPath(input);
|
|
const temporaryPath = path.join(
|
|
path.dirname(destination),
|
|
`.${path.basename(destination)}.${(dependencies.createNonce ?? randomUUID)()}.tmp`,
|
|
);
|
|
const fileSystem = dependencies.fileSystem ?? defaultFileSystem;
|
|
let ownsTemporaryFile = false;
|
|
try {
|
|
const handle = await fileSystem.openFile(
|
|
temporaryPath,
|
|
constants.O_WRONLY |
|
|
constants.O_CREAT |
|
|
constants.O_EXCL |
|
|
constants.O_NOFOLLOW,
|
|
0o600,
|
|
);
|
|
ownsTemporaryFile = true;
|
|
let primaryFailure: unknown;
|
|
try {
|
|
await handle.writeFile(`${serialized}\n`);
|
|
await handle.sync();
|
|
} catch (error) {
|
|
primaryFailure = error;
|
|
}
|
|
try {
|
|
await handle.close();
|
|
} catch (error) {
|
|
primaryFailure ??= error;
|
|
}
|
|
if (primaryFailure !== undefined) throw primaryFailure;
|
|
|
|
await fileSystem.rename(temporaryPath, destination);
|
|
ownsTemporaryFile = false;
|
|
const directoryHandle = await fileSystem.openDirectory(path.dirname(destination));
|
|
try {
|
|
try {
|
|
await directoryHandle.sync();
|
|
} catch (error) {
|
|
// Windows and some filesystems do not support fsync on directory handles.
|
|
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) {
|
|
throw error;
|
|
}
|
|
}
|
|
} finally {
|
|
await directoryHandle.close();
|
|
}
|
|
} catch (error) {
|
|
if (ownsTemporaryFile) {
|
|
try {
|
|
await fileSystem.rm(temporaryPath, { force: true });
|
|
} catch {
|
|
// Preserve the publication failure and clean only our nonce-owned path.
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|