@anthropic-ai/sdk
Version:
The official TypeScript library for the Anthropic API
552 lines (505 loc) • 20.8 kB
text/typescript
/**
* `FileStore` — one confined folder; a relative path cannot escape it.
*
* Beta scope: symlinks are refused or skipped wherever the store meets them,
* but there is no hardening against a process racing the store's own
* syscalls; fsync durability, non-POSIX hosts, and read-size caps are out of
* scope.
*/
import type { BigIntStats, Dirent } from 'node:fs';
import type { FileHandle } from 'node:fs/promises';
import type * as util from 'node:util';
import { crypto, fs, path } from './node';
import { encodeUTF8 } from './utils/bytes';
const fsp = fs.promises;
const C = fs.constants;
// Owner-only regardless of umask: the store holds downloaded user/model content.
const OWNER_ONLY_DIR_MODE = 0o700;
const OWNER_ONLY_FILE_MODE = 0o600;
const OWNER_ONLY_EXEC_MODE = 0o700;
// 0 where the platform lacks them; `open` refuses such platforms.
const O_NOFOLLOW: number = (C as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0;
const O_NONBLOCK: number = (C as { O_NONBLOCK?: number }).O_NONBLOCK ?? 0;
/** A refused operation — input the store will not act on. OS errors propagate with their `.code`. */
export class FileStoreError extends Error {
static readonly ESCAPES_ROOT = 'escapes the store root';
static readonly IS_A_SYMLINK = 'is a symlink';
static readonly NOT_A_FILE = 'is not a regular file';
static readonly NOT_A_DIRECTORY = 'is not a directory';
static readonly NOT_UTF8 = 'is not valid utf-8';
static readonly MOVE_DESTINATION_EXISTS = 'already exists';
readonly reason: string;
readonly relPath: string;
constructor(reason: string, relPath: string) {
super(`path ${JSON.stringify(relPath)} ${reason}`);
this.name = 'FileStoreError';
this.reason = reason;
this.relPath = relPath;
}
}
/**
* The store's resolved root, and what {@link FileStore.dispose} will do to it.
*
* `removedOnDispose` is true when `open` found no root, so `dispose` removes
* it. A root that was already there is someone else's — a pre-seeded mount, a
* caller's workdir — and is kept.
*/
export type Root = { path: string; removedOnDispose: boolean };
/** Options for {@link FileStore.open} / {@link openFileStore}. */
export interface OpenFileStoreOptions {
/**
* Restrict the store to valid UTF-8 (default `false`): a `put` of binary
* bytes and a `get` of a binary file are refused with
* {@link FileStoreError}, so a caller that decodes what `get` returns can
* never hit a decode error.
*/
utf8?: boolean;
}
/** Resolve `root`; creates nothing — only {@link FileStore.createRoot} makes the folder. */
export async function openFileStore(root: string, opts?: OpenFileStoreOptions): Promise<FileStore> {
return FileStore.open(root, opts);
}
/**
* True for a path usable verbatim as a store location: absolute, with no `..`
* components. Paths are judged in POSIX terms — they are wire values naming
* locations inside a POSIX container, not host-native paths.
*/
export function isPathLegal(p: string): boolean {
return p.startsWith('/') && !p.split('/').includes('..');
}
/** One hashed file version: the stat identity it had, and its sha. */
type Hashed = {
mtimeNs: bigint;
// Userspace cannot set ctime, so writers that preserve mtimes
// (`rsync -t`, `cp -p`) still miss the cache.
ctimeNs: bigint;
size: bigint;
sha: string;
};
/**
* One confined folder of regular files.
*
* Every `relPath` is relative to the root (a leading `/` also means the root)
* and refused with {@link FileStoreError} when it escapes. The store holds
* regular files only: symlinks are refused on read and skipped by listings —
* {@link findSymlinks} reports them. A `relPath` resolving to the root itself
* is banned by this interface: `put` and `get` refuse it, `move` and `remove`
* do nothing. A store opened with `utf8: true` refuses binary content the
* same way — on `put` of such bytes and on `get` of such a file. Only
* {@link createRoot} makes the root: writes create directories below it,
* never the root itself, so a root removed while the store is open stays
* removed and the write fails with `ENOENT`.
*/
export class FileStore {
private readonly rootPath: string;
/** True ⇒ this open created the root, so {@link dispose} removes it. */
private readonly removedOnDispose: boolean;
/** Set iff the store refuses non-UTF-8 content; {@link requireUtf8} decodes against it, on both put and get. */
private readonly decoder: util.TextDecoder | undefined;
/** `hashtree`'s advisory cache; every hit re-validates against a fresh stat. */
private readonly hashes = new Map<string, Hashed>();
static isPathLegal = isPathLegal;
/** @internal — use {@link FileStore.open} / {@link openFileStore}. */
constructor(root: string, removedOnDispose: boolean, utf8Only: boolean = false) {
this.rootPath = root;
this.removedOnDispose = removedOnDispose;
this.decoder = utf8Only ? new TextDecoder('utf-8', { fatal: true }) : undefined;
}
/** Resolve `root`; creates nothing — only {@link createRoot} makes the folder. */
static async open(root: string, opts?: OpenFileStoreOptions): Promise<FileStore> {
// A deployment condition, not refused caller input — hence not FileStoreError.
if (!platformSupported()) {
throw new Error('FileStore requires O_NOFOLLOW support on this platform');
}
let removedOnDispose = false;
try {
// lstat, not a follow-and-swallow existence check: following symlinks or
// swallowing permission errors would mark a real directory ours to
// delete on dispose.
await fsp.lstat(root);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
removedOnDispose = true;
}
return new FileStore(path.resolve(root), removedOnDispose, opts?.utf8 ?? false);
}
/** Create the root directory and any missing ancestors; already existing is fine. */
async createRoot(): Promise<void> {
await makeDirAndAncestors(this.rootPath);
}
/** The resolved root, and what {@link dispose} will do to it. */
root(): Root {
return { path: this.rootPath, removedOnDispose: this.removedOnDispose };
}
/**
* Remove the root iff `open` created it; pre-existing roots are kept.
*
* Wired to `Symbol.asyncDispose` at runtime when the host provides it, so
* `await using` works on engines with explicit resource management.
*/
async dispose(): Promise<void> {
if (!this.removedOnDispose) return;
await fsp.rm(this.rootPath, { recursive: true, force: true });
}
/**
* Write `data` (`string` UTF-8 or bytes) atomically to the file at `relPath`.
*
* Missing directories below the root are created; a missing root is not —
* the write fails with `ENOENT`.
*/
async put(relPath: string, data: string | Uint8Array, opts?: { executable?: boolean }): Promise<void> {
// "dir/." names a directory just like a trailing "/".
const tail = relPath.replace(/\\/g, '/');
if (tail.endsWith('/') || tail.endsWith('/.') || tail === '' || tail === '.') {
throw new FileStoreError(FileStoreError.NOT_A_FILE, relPath);
}
const dest = this.resolveUnderRoot(relPath);
const payload = typeof data === 'string' ? encodeUTF8(data) : data;
this.requireUtf8(relPath, payload);
await makeDirsBelowRoot(this.rootPath, path.dirname(dest));
await replaceViaTemp(dest, payload, opts?.executable ?? false);
}
/** The file's bytes; `null` when absent. */
async get(relPath: string): Promise<Uint8Array | null> {
const dest = this.resolveUnderRoot(relPath);
let handle: FileHandle;
try {
handle = await openRegularFile(relPath, dest);
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw e;
}
let data: Uint8Array;
try {
const buf = await handle.readFile();
data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
} finally {
await handle.close();
}
this.requireUtf8(relPath, data);
return data;
}
/** The relative path of every file under the directory `under`. */
async ls(under: string = '/'): Promise<Set<string>> {
const base = this.resolveUnderRoot(under);
return new Set((await filenamesInDir(this.rootPath, under, base)).map(([rel]) => rel));
}
/**
* Every symlink under `under` — listings skip them and reads refuse them,
* so a caller that must know they exist asks here.
*/
async findSymlinks(under: string = '/'): Promise<Set<string>> {
const base = this.resolveUnderRoot(under);
return symlinksInDir(this.rootPath, under, base);
}
/**
* `{relPath: sha256Hex}` of every file under the directory `under`.
*
* Unchanged files — same size, mtime, and ctime since the last call —
* reuse their recorded hash instead of being re-read.
*/
async hashtree(under: string = '/'): Promise<Record<string, string>> {
const base = this.resolveUnderRoot(under);
const walkStartNs = _internals.nowNs();
// Null prototype so a file named `__proto__` (or `constructor`) is an
// ordinary own key instead of a silent prototype write / inherited read.
const out: Record<string, string> = Object.create(null);
for (const [rel, full] of await filenamesInDir(this.rootPath, under, base)) {
const sha = await this.hashViaCache(rel, full, walkStartNs);
if (sha !== null) out[rel] = sha;
}
return out;
}
/** One file's sha256; `null` when absent. Shares {@link hashtree}'s cache. */
async hashFile(relPath: string): Promise<string | null> {
const dest = this.resolveUnderRoot(relPath);
let st: BigIntStats;
try {
st = await fsp.lstat(dest, { bigint: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw e;
}
if (st.isSymbolicLink()) throw new FileStoreError(FileStoreError.IS_A_SYMLINK, relPath);
if (!st.isFile()) throw new FileStoreError(FileStoreError.NOT_A_FILE, relPath);
const rel = path.relative(this.rootPath, dest).split(path.sep).join('/');
return this.hashViaCache(rel, dest, _internals.nowNs());
}
/**
* Rename `src` to `dst`; an existing `dst` is refused. The banned store
* root as either end does nothing.
*/
async move(src: string, dst: string): Promise<void> {
const s = this.resolveUnderRoot(src);
const d = this.resolveUnderRoot(dst);
if (s === this.rootPath || d === this.rootPath) return;
// stat, not lstat: a dangling symlink at dst reads as absent and is
// atomically replaced by the rename, like any other rename target.
const dstExists = await fsp.stat(d).then(
() => true,
() => false,
);
if (dstExists) throw new FileStoreError(FileStoreError.MOVE_DESTINATION_EXISTS, dst);
await makeDirsBelowRoot(this.rootPath, path.dirname(d));
await fsp.rename(s, d);
}
/** Delete a file or subtree; absent — and the banned store root — do nothing. */
async remove(relPath: string): Promise<void> {
const dest = this.resolveUnderRoot(relPath);
if (dest === this.rootPath) return;
let st: BigIntStats;
try {
// lstat: a dangling symlink must still be unlinked.
st = await fsp.lstat(dest, { bigint: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return;
throw e;
}
if (st.isDirectory()) {
await fsp.rm(dest, { recursive: true, force: true });
} else {
try {
await fsp.unlink(dest);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
}
}
}
private resolveUnderRoot(relPath: string): string {
const norm = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
const parts = norm.split('/').filter((p) => p !== '' && p !== '.');
if (path.posix.isAbsolute(norm) || parts.includes('..')) {
throw new FileStoreError(FileStoreError.ESCAPES_ROOT, relPath);
}
return parts.length === 0 ? this.rootPath : path.join(this.rootPath, ...parts);
}
private requireUtf8(relPath: string, data: Uint8Array): void {
if (!this.decoder) return;
try {
this.decoder.decode(data);
} catch {
throw new FileStoreError(FileStoreError.NOT_UTF8, relPath);
}
}
private async hashViaCache(rel: string, full: string, walkStartNs: bigint): Promise<string | null> {
let st: BigIntStats;
try {
st = await fsp.lstat(full, { bigint: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; // vanished since the walk: not in this snapshot
throw e;
}
if (!st.isFile()) return null;
const cached = this.hashes.get(rel);
let sha: string;
if (cached !== undefined && unchangedSinceHashed(cached, st)) {
sha = cached.sha;
} else {
try {
sha = await _internals.hashFile(full);
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || e instanceof FileStoreError) return null;
// FreeBSD reports EMLINK rather than ELOOP for O_NOFOLLOW.
if (code === 'ELOOP' || code === 'EMLINK') return null;
throw e;
}
}
if (oldEnoughToCache(st, walkStartNs)) {
this.hashes.set(rel, { mtimeNs: st.mtimeNs, ctimeNs: st.ctimeNs, size: st.size, sha });
}
return sha;
}
}
function platformSupported(): boolean {
return O_NOFOLLOW !== 0;
}
async function makeDirAndAncestors(dir: string): Promise<void> {
const missing: string[] = [];
let current = dir;
for (;;) {
try {
await fsp.stat(current);
break;
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') throw e;
}
missing.push(current);
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
for (const directory of missing.reverse()) {
try {
await fsp.mkdir(directory, { mode: OWNER_ONLY_DIR_MODE });
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
}
}
}
async function makeDirsBelowRoot(root: string, dir: string): Promise<void> {
// Never the root itself: only createRoot() makes it, so a write racing an
// rm -rf of the folder fails with ENOENT instead of re-creating it.
const below = path.relative(root, dir);
if (below === '') return;
let current = root;
for (const part of below.split(path.sep)) {
current = path.join(current, part);
try {
await fsp.mkdir(current, { mode: OWNER_ONLY_DIR_MODE });
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
}
}
}
async function replaceViaTemp(dest: string, data: Uint8Array, isExecutable: boolean): Promise<void> {
const mode = isExecutable ? OWNER_ONLY_EXEC_MODE : OWNER_ONLY_FILE_MODE;
const tmp = path.join(path.dirname(dest), `.fs-${crypto.randomBytes(8).toString('hex')}.tmp`);
let handle: FileHandle | undefined;
try {
handle = await fsp.open(tmp, C.O_WRONLY | C.O_CREAT | C.O_EXCL | O_NOFOLLOW, mode);
await handle.writeFile(data);
await handle.close();
handle = undefined;
await fsp.rename(tmp, dest);
} catch (err) {
// Best-effort temp cleanup; never mask the original error.
if (handle) await handle.close().catch(() => {});
await fsp.unlink(tmp).catch(() => {});
throw err;
}
}
async function openRegularFile(relPath: string, dest: string): Promise<FileHandle> {
// O_NONBLOCK: a FIFO fails the fstat check below instead of blocking the open.
let handle: FileHandle;
try {
handle = await fsp.open(dest, C.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
} catch (e) {
// FreeBSD reports EMLINK rather than ELOOP for O_NOFOLLOW.
const code = (e as NodeJS.ErrnoException).code;
if (code === 'ELOOP' || code === 'EMLINK') {
throw new FileStoreError(FileStoreError.IS_A_SYMLINK, relPath);
}
throw e;
}
try {
const st = await handle.stat();
if (!st.isFile()) throw new FileStoreError(FileStoreError.NOT_A_FILE, relPath);
} catch (e) {
await handle.close().catch(() => {});
throw e;
}
return handle;
}
/** sha256 of a file's contents, streamed — constant memory on any file size. */
async function hashFile(full: string): Promise<string> {
const digest = crypto.createHash('sha256');
const handle = await openRegularFile(path.basename(full), full);
const buf = new Uint8Array(1024 * 1024);
try {
for (;;) {
const { bytesRead } = await handle.read(buf, 0, buf.length);
if (bytesRead === 0) break;
digest.update(buf.subarray(0, bytesRead));
}
} finally {
await handle.close();
}
return digest.digest('hex');
}
/**
* `[rel, path]` for every regular file under the directory `base`; an absent
* `base` is empty, a present non-directory is refused. The walk never
* descends symlinked directories.
*/
async function filenamesInDir(root: string, under: string, base: string): Promise<[string, string][]> {
if (!(await requireDir(under, base))) return [];
const out: [string, string][] = [];
await walk(base, (full, entry) => {
if (entry.isFile()) out.push([path.relative(root, full).split(path.sep).join('/'), full]);
});
out.sort();
return out;
}
async function symlinksInDir(root: string, under: string, base: string): Promise<Set<string>> {
const relOf = (full: string) => path.relative(root, full).split(path.sep).join('/');
let st: BigIntStats;
try {
st = await fsp.lstat(base, { bigint: true });
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') return new Set();
throw e;
}
if (st.isSymbolicLink()) return new Set([relOf(base)]);
if (!st.isDirectory()) throw new FileStoreError(FileStoreError.NOT_A_DIRECTORY, under);
const out = new Set<string>();
// Symlinks to directories are reported, never descended, like the rest.
await walk(base, (full, entry) => {
if (entry.isSymbolicLink()) out.add(relOf(full));
});
return out;
}
/** `false` when `base` is absent, refused when present but not a directory. */
async function requireDir(under: string, base: string): Promise<boolean> {
let st: BigIntStats;
try {
st = await fsp.lstat(base, { bigint: true });
} catch (e) {
// Absent — including "under a file" (ENOTDIR) — is an empty listing.
const code = (e as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
throw e;
}
if (!st.isDirectory()) throw new FileStoreError(FileStoreError.NOT_A_DIRECTORY, under);
return true;
}
/** Visit every entry under `base` without descending symlinked directories. */
async function walk(base: string, visit: (full: string, entry: Dirent) => void): Promise<void> {
const stack: string[] = [base];
while (stack.length) {
const dir = stack.pop()!;
let entries: Dirent[];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') continue; // a listing is a snapshot, not a lock
throw e;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
visit(full, entry);
if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(full);
}
}
}
function unchangedSinceHashed(cached: Hashed, st: BigIntStats): boolean {
return st.mtimeNs === cached.mtimeNs && st.ctimeNs === cached.ctimeNs && st.size === cached.size;
}
function oldEnoughToCache(st: BigIntStats, walkStartNs: bigint): boolean {
const newestNs = st.mtimeNs > st.ctimeNs ? st.mtimeNs : st.ctimeNs;
return newestNs < walkStartNs - _internals.timestampTrustMarginNs;
}
// Filesystems stamp times with coarse clocks, so a rewrite shortly after a
// hashed write can reuse the exact stamps. Files younger than the margin are
// simply re-hashed next walk.
const TIMESTAMP_TRUST_MARGIN_NS = 2_000_000_000n;
/** Test seam — the hasher, the trust margin, and the walk clock. @internal */
export const _internals = {
hashFile,
timestampTrustMarginNs: TIMESTAMP_TRUST_MARGIN_NS,
nowNs: (): bigint => BigInt(Date.now()) * 1_000_000n,
};
export const LocalFileStore = FileStore;
// Wire `Symbol.asyncDispose` at runtime when the host provides it — the
// repo's tsconfig targets ES2020 so the type-level `AsyncDisposable` lib is
// not available, but `await using` callers on newer engines still work.
const asyncDispose = (Symbol as { asyncDispose?: symbol }).asyncDispose;
if (asyncDispose) {
Object.defineProperty(FileStore.prototype, asyncDispose, {
value: FileStore.prototype.dispose,
configurable: true,
writable: true,
});
}