Skill Tracer
Skills › Archify › archify › owned-temp-directory.mjs

owned-temp-directory.mjs

82 lines · archify/renderers/shared/owned-temp-directory.mjs at a406594

1import fs from 'node:fs';
2import os from 'node:os';
3import path from 'node:path';
4import { setTimeout as delay } from 'node:timers/promises';
5import { sameEntry } from './path-semantics.mjs';
6
7const retryable = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']);
8
9function directoryIdentity(directory) {
10 const stat = fs.lstatSync(directory, { bigint: true });
11 if (!stat.isDirectory() || stat.isSymbolicLink() || stat.ino === 0n) {
12 throw new Error(`Temporary directory identity is unavailable or changed: ${directory}`);
13 }
14 return { directory, dev: stat.dev, ino: stat.ino, birthtimeNs: stat.birthtimeNs };
17function verifyIdentity(expected) {
18 const current = directoryIdentity(expected.directory);
19 if (current.dev !== expected.dev || current.ino !== expected.ino
20 || current.birthtimeNs !== expected.birthtimeNs) {
21 throw new Error(`Temporary directory identity changed: ${expected.directory}`);
22 }
25// Bind cleanup to this creation, never to a caller-supplied deletion path.
26// This detects substitution between creation and cleanup (including junctions)
27// and between retries. Path-based Node APIs cannot eliminate a hostile mutation
28// racing the final check and rm; this is not a filesystem sandbox.
29export function createOwnedTempDirectory(prefix, { parent = os.tmpdir() } = {}) {
30 if (!/^[a-zA-Z0-9-]+-$/.test(prefix)) throw new Error('Invalid temporary directory prefix');
31 const canonicalParent = fs.realpathSync.native(path.resolve(parent));
32 const ancestors = [];
33 for (let current = canonicalParent; ; current = path.dirname(current)) {
34 ancestors.push(directoryIdentity(current));
35 const parentRelation = sameEntry(current, path.dirname(current));
36 if (parentRelation.status === 'match') break;
37 if (parentRelation.status !== 'different') {
38 throw new Error(`Temporary directory ancestor identity is unavailable: ${current}`);
39 }
40 }
41 const verifyAncestors = () => {
42 // Check from the filesystem root down, before traversing a replaced parent.
43 for (const ancestor of [...ancestors].reverse()) verifyIdentity(ancestor);
44 };
45 verifyAncestors();
46 const directory = fs.mkdtempSync(path.join(canonicalParent, prefix));
47 verifyAncestors();
48 const identity = directoryIdentity(directory);
50 function remove() {
51 verifyAncestors();
52 try {
53 verifyIdentity(identity);
54 } catch (error) {
55 if (error.code === 'ENOENT') return;
56 throw error;
57 }
58 // No internal retry: each new attempt must revalidate the entire binding.
59 fs.rmSync(directory, { recursive: true, force: false, maxRetries: 0 });
60 }
62 return Object.freeze({
63 path: directory,
64 async cleanup() {
65 for (let attempt = 0; ; attempt++) {
66 try {
67 remove();
68 return;
69 } catch (cause) {
70 if (retryable.has(cause.code) && attempt < 3) {
71 await delay(100 * (attempt + 1));
72 continue;
73 }
74 const error = new Error(`Temporary directory cleanup failed; retained path "${directory}": ${cause.message}`, { cause });
75 error.code = 'ARCHIFY_TEMP_CLEANUP';
76 error.directory = directory;
77 throw error;
78 }
79 }
80 },
81 });