100 lines
2.6 KiB
TypeScript
100 lines
2.6 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import {
|
|
assertSha256Digest,
|
|
type Sha256Digest,
|
|
} from "../shared/build-protocol";
|
|
|
|
function digest(data: Uint8Array): Sha256Digest {
|
|
return `sha256:${createHash("sha256").update(data).digest("hex")}`;
|
|
}
|
|
|
|
export class FilesystemCas {
|
|
readonly root: string;
|
|
|
|
constructor(root: string) {
|
|
this.root = root;
|
|
}
|
|
|
|
private path(blobDigest: Sha256Digest): string {
|
|
assertSha256Digest(blobDigest);
|
|
return join(
|
|
this.root,
|
|
"sha256",
|
|
blobDigest.slice(7, 9),
|
|
blobDigest.slice(9),
|
|
);
|
|
}
|
|
|
|
async has(blobDigest: Sha256Digest): Promise<boolean> {
|
|
try {
|
|
return (await lstat(this.path(blobDigest))).isFile();
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async put(data: Uint8Array, expected?: Sha256Digest): Promise<Sha256Digest> {
|
|
const actual = digest(data);
|
|
if (expected !== undefined) {
|
|
assertSha256Digest(expected);
|
|
if (actual !== expected)
|
|
throw new Error(
|
|
`CAS digest mismatch: expected ${expected}, got ${actual}`,
|
|
);
|
|
}
|
|
|
|
const target = this.path(actual);
|
|
const directory = dirname(target);
|
|
await mkdir(directory, { recursive: true, mode: 0o755 });
|
|
if (await this.has(actual)) {
|
|
await this.get(actual);
|
|
return actual;
|
|
}
|
|
|
|
const temporary = `${target}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
const handle = await open(
|
|
temporary,
|
|
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
|
|
0o644,
|
|
);
|
|
try {
|
|
await handle.writeFile(data);
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
try {
|
|
await rename(temporary, target);
|
|
} finally {
|
|
await rm(temporary, { force: true });
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
async get(blobDigest: Sha256Digest): Promise<Uint8Array> {
|
|
const handle = await open(
|
|
this.path(blobDigest),
|
|
constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
);
|
|
let data: Uint8Array;
|
|
try {
|
|
const blobStat = await handle.stat();
|
|
if (!blobStat.isFile())
|
|
throw new Error(`CAS blob is not a regular file: ${blobDigest}`);
|
|
data = await handle.readFile();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
const actual = digest(data);
|
|
if (actual !== blobDigest)
|
|
throw new Error(
|
|
`Corrupt CAS blob: expected ${blobDigest}, got ${actual}`,
|
|
);
|
|
return data;
|
|
}
|
|
}
|