@stacksjs/storage 0.75.230 → 0.75.233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,6 +104,108 @@ authorization and domain-schema validation. This is a local-filesystem protocol
104
104
  requiring hard links and directory fsync, with no storage hardware power-loss
105
105
  guarantee. It is not an S3/disk-adapter transaction or content authentication.
106
106
 
107
+ ### Prepare an empty artifact before recording ownership
108
+
109
+ Use the synchronous prepared API when a caller must durably record the exact
110
+ owned empty files before any private payload reaches them:
111
+
112
+ ```ts
113
+ import { prepareCompletedArtifactDirectorySync } from '@stacksjs/storage/completed-artifact-directory'
114
+
115
+ const prepared = prepareCompletedArtifactDirectorySync(newDirectory, [
116
+ { path: 'images/preview.bin', contents: previewBytes },
117
+ ], manifestJson, { directories: ['empty'] })
118
+
119
+ // Persist prepared.receipt in the caller's ownership ledger before publication.
120
+ // Every registered file is still zero length; manifest.json is absent.
121
+ const completed = prepared.publishSync()
122
+ ```
123
+
124
+ The root package exports the same function and types. The frozen handle has
125
+ only `receipt` and `publishSync`. `PreparedCompletedArtifactReceipt` is deeply
126
+ frozen, JSON-serializable metadata: canonical `directory`, resolved original
127
+ `lexicalDirectory`, `parent` and `root` identities, `directories`, `files`,
128
+ `pendingManifest` and the initially absent `completedManifest` alias path.
129
+ `CompletedArtifactIdentity` records canonical `path`, `type`, permission `mode`,
130
+ and decimal-string `dev`, `ino`, `uid`, `nlink` and `birthtimeNs` observations.
131
+ Each `PreparedCompletedArtifactFile` also records planned final `bytes` and
132
+ `sha256`, plus `preparationCtimeNs`. **Planned lengths and hashes do not mean
133
+ that bytes have been written. All observed file lengths are zero on return.**
134
+ Receipts contain neither payload buffers nor the manifest body. Hashes provide
135
+ unsigned consistency metadata, not content authentication.
136
+
137
+ Preparation validates, accounts and detaches all inputs before filesystem
138
+ mutation. Records and lists must contain only declared own data properties;
139
+ proxies, getters, holes, custom lists, hidden/symbol aliases and shared memory
140
+ are refused without executing caller code. Genuine Uint8Array and Buffer views
141
+ are copied at their intrinsic offset and length; overriding their iterator or
142
+ buffer/length getters cannot influence the copy. The existing limits remain
143
+ 1,024 data files, 128 MiB including one manifest, 16 MiB UTF-8 manifest and four
144
+ relative path components. Files are exclusive 0600 and owned directories 0700.
145
+
146
+ The configured parent must already exist, belong to the calling operator and
147
+ remain operator controlled. Its canonical path permits normal ancestor aliases
148
+ such as Darwin's `/var` to `/private/var`; an existing target is never adopted,
149
+ including a final symlink. Empty files and directory entries are synced, and
150
+ every descriptor is closed before the handle returns. Parent link count is an
151
+ observation: creating a sibling artifact can legitimately change it.
152
+
153
+ `publishSync()` consumes its live closure before work, even on failure. It
154
+ rechecks the parent, owned directory entries, private modes, identities, links
155
+ and empty files before payload writes. Writes use checked synchronous loops,
156
+ fsync and SHA-256 readback. The synced pending manifest receives the exclusive
157
+ completed hard-link alias, with file link counts checked through 1, 2, then 1 as
158
+ the pending alias is removed. Publication closes descriptors before reporting
159
+ success and releases its retained byte references on success or failure. This
160
+ does not claim secure memory erasure. Borrowing the method does not change its
161
+ target or single-use state. Serialized receipts cannot recreate a writer.
162
+
163
+ Errors retain partial state for explicit caller reconciliation. There is no
164
+ overwrite option, existing-directory writer, implicit cleanup or public restart
165
+ writer. Birth time strengthens replacement detection where the filesystem
166
+ supports it; zero means unavailable, and coarse timestamps or inode reuse do
167
+ not establish an absolute generation guarantee. Change time is checked only
168
+ for the empty preallocation barrier, since legitimate writes and links change
169
+ it. Do not treat it as an immutable identity for later deletion.
170
+
171
+ Both calls block the isolate and run without application callbacks or event-loop
172
+ pumping. Byte/file caps bound work, not I/O duration. One local Bun 1.4.3 Darwin
173
+ run measured prepare/publication at about 15/30 ms for 32 MiB and 71/43 ms for
174
+ 1,024 empty data files. These are observations, not latency guarantees. A call
175
+ inside a database transaction also holds that transaction while the isolate
176
+ blocks; this database-free module does not qualify cross-process database
177
+ contention or SQL/filesystem atomicity. The existing `writeCompletedArtifact`
178
+ still returns a Promise, snapshots before its first await and performs
179
+ asynchronous filesystem I/O.
180
+
181
+ Real local tests cover metadata barriers, offsets, mutation, input nonexecution,
182
+ caps, changed identities/modes/links, symlinks, FIFOs, descriptor closure, and
183
+ child termination at empty, payload and pending-manifest windows. They do not
184
+ qualify privileged device-node replacement, unsupported filesystems, disk-full
185
+ or hardware fault injection, physical power loss, media erasure or uncontrolled
186
+ copies. The protocol requires POSIX no-follow/nonblocking/directory opens, hard
187
+ links and directory fsync. It assumes an unmodified native runtime and controlled
188
+ parents; portable Node APIs do not provide a complete descriptor-relative openat
189
+ tree protocol or protection from a hostile administrator, same-user process,
190
+ thread or addon. Callers own authorization, SQL fences and process quiescence.
191
+
192
+ ### Read-only directory mount inspection
193
+
194
+ `inspectDirectoryMounts(canonicalDirectory)` is exported from the package root
195
+ and `temporary-workspaces` subpath. It inspects the current process namespace's
196
+ mount table without scanning processes or requiring active readers to stop.
197
+ `DirectoryMountInspection` reports `unmounted` and a reason: `unmounted`,
198
+ `mounted`, `inspection-unavailable` or `unsupported-platform`. Invalid, missing,
199
+ non-directory, symlink or noncanonical paths return unavailable; canonicalize a
200
+ previously checked directory with `realpathSync` first. Unavailable observations
201
+ include a bounded operation/code, without copying exception text.
202
+
203
+ An unmounted observation is a point-in-time namespace check, not ownership,
204
+ process quiescence or permission to delete. It does not inspect other process
205
+ namespaces or prevent later host changes. Keep `inspectTemporaryWorkspace` and
206
+ the journal's retirement protocol for recovery/deletion decisions; those checks
207
+ still require process visibility and have unchanged behavior.
208
+
107
209
  ## 🧪 Testing
108
210
 
109
211
  ```bash
@@ -1,3 +1,4 @@
1
+ export type { CompletedArtifactIdentity, PreparedCompletedArtifactDirectory, PreparedCompletedArtifactFile, PreparedCompletedArtifactReceipt } from './prepared-completed-artifact-directory';
1
2
  /**
2
3
  * Publish a new private local artifact after syncing its files and directory entries.
3
4
  * Parents must be operator controlled. Requires hard links and directory fsync;
@@ -6,3 +7,4 @@
6
7
  export declare function writeCompletedArtifact(directory: string, entries: readonly CompletedArtifactEntry[], manifestJson: string, options?: { directories?: readonly string[] }): Promise<{ directory: string, manifest: string }>;
7
8
  export declare const completedArtifactLimits: unknown;
8
9
  export declare interface CompletedArtifactEntry { path: string, contents: string | Uint8Array }
10
+ export { prepareCompletedArtifactDirectorySync } from './prepared-completed-artifact-directory';
@@ -1 +1 @@
1
- import{link,mkdir,open,unlink}from"node:fs/promises";import{dirname,join,resolve}from"node:path";import{Buffer}from"node:buffer";export const completedArtifactLimits=Object.freeze({files:1024,bytes:134217728,manifestBytes:16777216,depth:4});export async function writeCompletedArtifact(directory,entries,manifestJson,options={}){if(typeof directory!=="string"||directory.length<1||directory.length>4096||directory.includes("\x00"))throw TypeError("Invalid artifact directory");if(!Array.isArray(entries)||entries.length>completedArtifactLimits.files||typeof manifestJson!=="string"||manifestJson.length>completedArtifactLimits.manifestBytes)throw RangeError("Artifact resource limit");const manifestSize=Buffer.byteLength(manifestJson,"utf8");if(manifestSize>completedArtifactLimits.manifestBytes)throw RangeError("Completion manifest byte limit");let manifest;try{manifest=JSON.parse(manifestJson)}catch{throw TypeError("Invalid completion manifest JSON")}if(!manifest||typeof manifest!=="object"||Array.isArray(manifest))throw TypeError("Completion manifest must be a JSON object");let total=manifestSize;const paths=new Set(["manifest.json","manifest.pending.json"]);function validatePath(path){if(typeof path!=="string"||path.length>240||!/^[a-z0-9._/-]+$/i.test(path))throw TypeError("Invalid artifact entry path");const parts=path.split("/");if(parts.length>completedArtifactLimits.depth||parts.some((part)=>!part||part==="."||part===".."||part.length>128))throw TypeError("Unsafe artifact entry path")}const files=entries.map((entry)=>{if(!entry||typeof entry!=="object"||Object.keys(entry).length!==2||!Object.hasOwn(entry,"path")||!Object.hasOwn(entry,"contents"))throw TypeError("Invalid artifact entry");validatePath(entry.path);const key=entry.path.toLowerCase();if(paths.has(key))throw TypeError("Duplicate or reserved artifact path");paths.add(key);let size;if(typeof entry.contents==="string")size=Buffer.byteLength(entry.contents,"utf8");else if(entry.contents instanceof Uint8Array&&!(typeof SharedArrayBuffer<"u"&&entry.contents.buffer instanceof SharedArrayBuffer))size=entry.contents.length;else throw TypeError("Artifact contents must be text or unshared bytes");total+=size;if(total>completedArtifactLimits.bytes)throw RangeError("Artifact byte limit");return{path:entry.path,contents:entry.contents}});if(!options||typeof options!=="object"||Object.keys(options).some((key)=>key!=="directories")||options.directories!==void 0&&(!Array.isArray(options.directories)||options.directories.length>completedArtifactLimits.files))throw TypeError("Invalid artifact directory options");const directorySpellings=new Map;function registerDirectories(path,includeLeaf){const parts=path.split("/");for(let depth=1;depth<=parts.length-(includeLeaf?0:1);depth++){const parent=parts.slice(0,depth).join("/"),key=parent.toLowerCase();if(paths.has(key))throw TypeError("Artifact directory and file collision");const spelling=directorySpellings.get(key);if(spelling!==void 0&&spelling!==parent)throw TypeError("Artifact directory case alias collision");directorySpellings.set(key,parent)}}for(const path of options.directories??[]){validatePath(path);registerDirectories(path,!0)}for(const file of files)registerDirectories(file.path,!1);const manifestBytes=new TextEncoder().encode(manifestJson),snapshot=files.map((file)=>({path:file.path,bytes:typeof file.contents==="string"?new TextEncoder().encode(file.contents):new Uint8Array(file.contents)})),target=resolve(directory),directories=[...directorySpellings.values()];async function writeSynced(path,data){const file=await open(path,"wx",384);try{await file.writeFile(data);await file.sync()}finally{await file.close()}}async function syncDirectory(path){const folder=await open(path,"r");try{await folder.sync()}finally{await folder.close()}}await mkdir(target,{mode:448});for(const path of[...directories].sort((a,b)=>a.split("/").length-b.split("/").length))await mkdir(join(target,path),{mode:448});for(const file of snapshot)await writeSynced(join(target,file.path),file.bytes);for(const path of[...directories].sort((a,b)=>b.split("/").length-a.split("/").length))await syncDirectory(join(target,path));await syncDirectory(target);await syncDirectory(dirname(target));const pending=join(target,"manifest.pending.json"),completed=join(target,"manifest.json");await writeSynced(pending,manifestBytes);await link(pending,completed);await syncDirectory(target);await unlink(pending);await syncDirectory(target);return{directory:target,manifest:completed}}
1
+ import{link,mkdir,open,unlink}from"node:fs/promises";import{dirname,join,resolve}from"node:path";import{Buffer}from"node:buffer";export{prepareCompletedArtifactDirectorySync}from"./prepared-completed-artifact-directory";export const completedArtifactLimits=Object.freeze({files:1024,bytes:134217728,manifestBytes:16777216,depth:4});export async function writeCompletedArtifact(directory,entries,manifestJson,options={}){if(typeof directory!=="string"||directory.length<1||directory.length>4096||directory.includes("\x00"))throw TypeError("Invalid artifact directory");if(!Array.isArray(entries)||entries.length>completedArtifactLimits.files||typeof manifestJson!=="string"||manifestJson.length>completedArtifactLimits.manifestBytes)throw RangeError("Artifact resource limit");const manifestSize=Buffer.byteLength(manifestJson,"utf8");if(manifestSize>completedArtifactLimits.manifestBytes)throw RangeError("Completion manifest byte limit");let manifest;try{manifest=JSON.parse(manifestJson)}catch{throw TypeError("Invalid completion manifest JSON")}if(!manifest||typeof manifest!=="object"||Array.isArray(manifest))throw TypeError("Completion manifest must be a JSON object");let total=manifestSize;const paths=new Set(["manifest.json","manifest.pending.json"]);function validatePath(path){if(typeof path!=="string"||path.length>240||!/^[a-z0-9._/-]+$/i.test(path))throw TypeError("Invalid artifact entry path");const parts=path.split("/");if(parts.length>completedArtifactLimits.depth||parts.some((part)=>!part||part==="."||part===".."||part.length>128))throw TypeError("Unsafe artifact entry path")}const files=entries.map((entry)=>{if(!entry||typeof entry!=="object"||Object.keys(entry).length!==2||!Object.hasOwn(entry,"path")||!Object.hasOwn(entry,"contents"))throw TypeError("Invalid artifact entry");validatePath(entry.path);const key=entry.path.toLowerCase();if(paths.has(key))throw TypeError("Duplicate or reserved artifact path");paths.add(key);let size;if(typeof entry.contents==="string")size=Buffer.byteLength(entry.contents,"utf8");else if(entry.contents instanceof Uint8Array&&!(typeof SharedArrayBuffer<"u"&&entry.contents.buffer instanceof SharedArrayBuffer))size=entry.contents.length;else throw TypeError("Artifact contents must be text or unshared bytes");total+=size;if(total>completedArtifactLimits.bytes)throw RangeError("Artifact byte limit");return{path:entry.path,contents:entry.contents}});if(!options||typeof options!=="object"||Object.keys(options).some((key)=>key!=="directories")||options.directories!==void 0&&(!Array.isArray(options.directories)||options.directories.length>completedArtifactLimits.files))throw TypeError("Invalid artifact directory options");const directorySpellings=new Map;function registerDirectories(path,includeLeaf){const parts=path.split("/");for(let depth=1;depth<=parts.length-(includeLeaf?0:1);depth++){const parent=parts.slice(0,depth).join("/"),key=parent.toLowerCase();if(paths.has(key))throw TypeError("Artifact directory and file collision");const spelling=directorySpellings.get(key);if(spelling!==void 0&&spelling!==parent)throw TypeError("Artifact directory case alias collision");directorySpellings.set(key,parent)}}for(const path of options.directories??[]){validatePath(path);registerDirectories(path,!0)}for(const file of files)registerDirectories(file.path,!1);const manifestBytes=new TextEncoder().encode(manifestJson),snapshot=files.map((file)=>({path:file.path,bytes:typeof file.contents==="string"?new TextEncoder().encode(file.contents):new Uint8Array(file.contents)})),target=resolve(directory),directories=[...directorySpellings.values()];async function writeSynced(path,data){const file=await open(path,"wx",384);try{await file.writeFile(data);await file.sync()}finally{await file.close()}}async function syncDirectory(path){const folder=await open(path,"r");try{await folder.sync()}finally{await folder.close()}}await mkdir(target,{mode:448});for(const path of[...directories].sort((a,b)=>a.split("/").length-b.split("/").length))await mkdir(join(target,path),{mode:448});for(const file of snapshot)await writeSynced(join(target,file.path),file.bytes);for(const path of[...directories].sort((a,b)=>b.split("/").length-a.split("/").length))await syncDirectory(join(target,path));await syncDirectory(target);await syncDirectory(dirname(target));const pending=join(target,"manifest.pending.json"),completed=join(target,"manifest.json");await writeSynced(pending,manifestBytes);await link(pending,completed);await syncDirectory(target);await unlink(pending);await syncDirectory(target);return{directory:target,manifest:completed}}
@@ -0,0 +1,37 @@
1
+ import type { CompletedArtifactEntry } from './completed-artifact-directory';
2
+ /**
3
+ * Exclusively preallocate an empty private tree, then publish through one live synchronous closure.
4
+ * Controlled parents and an unmodified native runtime are required. No restart writer or cleanup.
5
+ * This blocks the isolate; caps bound bytes, not latency or physical power-loss durability.
6
+ */
7
+ export declare function prepareCompletedArtifactDirectorySync(directory: string, entries: readonly CompletedArtifactEntry[], manifestJson: string, options?: { directories?: readonly string[] }): PreparedCompletedArtifactDirectory;
8
+ /** Observations from no-follow lstat/open/fstat agreement; decimal strings preserve bigint identity. */
9
+ export declare interface CompletedArtifactIdentity {
10
+ readonly path: string
11
+ readonly type: 'file' | 'directory'
12
+ readonly dev: string
13
+ readonly ino: string
14
+ readonly uid: string
15
+ readonly birthtimeNs: string
16
+ readonly nlink: string
17
+ readonly mode: number
18
+ }
19
+ export declare interface PreparedCompletedArtifactFile extends CompletedArtifactIdentity {
20
+ readonly preparationCtimeNs: string
21
+ readonly bytes: number
22
+ readonly sha256: string
23
+ }
24
+ export declare interface PreparedCompletedArtifactReceipt {
25
+ readonly directory: string
26
+ readonly lexicalDirectory: string
27
+ readonly parent: CompletedArtifactIdentity
28
+ readonly root: CompletedArtifactIdentity
29
+ readonly directories: readonly CompletedArtifactIdentity[]
30
+ readonly files: readonly PreparedCompletedArtifactFile[]
31
+ readonly pendingManifest: PreparedCompletedArtifactFile
32
+ readonly completedManifest: string
33
+ }
34
+ export declare interface PreparedCompletedArtifactDirectory {
35
+ readonly receipt: PreparedCompletedArtifactReceipt
36
+ readonly publishSync: () => { directory: string, manifest: string }
37
+ }
@@ -0,0 +1 @@
1
+ import{Buffer}from"node:buffer";import{createHash}from"node:crypto";import{closeSync,constants,fstatSync,fsyncSync,linkSync,lstatSync,mkdirSync,openSync,readdirSync,readSync,realpathSync,unlinkSync,writeSync}from"node:fs";import{basename,dirname,join,resolve}from"node:path";import{types}from"node:util";import{completedArtifactLimits}from"./completed-artifact-directory";const typedPrototype=Object.getPrototypeOf(Uint8Array.prototype),byteBuffer=Object.getOwnPropertyDescriptor(typedPrototype,"buffer").get,byteOffset=Object.getOwnPropertyDescriptor(typedPrototype,"byteOffset").get,byteLength=Object.getOwnPropertyDescriptor(typedPrototype,"byteLength").get,setBytes=Uint8Array.prototype.set;function dataRecord(value,allowed){if(!value||typeof value!=="object"||types.isProxy(value))throw TypeError("Artifact input must be plain data");const prototype=Object.getPrototypeOf(value);if(prototype!==Object.prototype&&prototype!==null)throw TypeError("Artifact input must be plain data");const descriptors=Object.getOwnPropertyDescriptors(value);for(const key of Reflect.ownKeys(descriptors)){const descriptor=typeof key==="string"?descriptors[key]:void 0;if(typeof key!=="string"||!allowed.includes(key)||!descriptor?.enumerable||!Object.hasOwn(descriptor,"value"))throw TypeError("Artifact input must have only declared data fields")}return descriptors}function dataList(value){if(!value||typeof value!=="object"||types.isProxy(value)||!Array.isArray(value)||Object.getPrototypeOf(value)!==Array.prototype)throw TypeError("Artifact input must be a plain list");const length=Object.getOwnPropertyDescriptor(value,"length")?.value;if(!Number.isSafeInteger(length)||length>completedArtifactLimits.files)throw RangeError("Artifact resource limit");const descriptors=Object.getOwnPropertyDescriptors(value);if(Reflect.ownKeys(descriptors).length!==length+1)throw TypeError("Artifact list cannot have holes or aliases");const result=[];for(let index=0;index<length;index++){const descriptor=descriptors[String(index)];if(!descriptor?.enumerable||!Object.hasOwn(descriptor,"value"))throw TypeError("Artifact list must contain data");result.push(descriptor.value)}return result}function entryPath(value){if(typeof value!=="string"||value.length>240||!/^[a-z0-9._/-]+$/i.test(value))throw TypeError("Invalid artifact entry path");const parts=value.split("/");if(parts.length>completedArtifactLimits.depth||parts.some((part)=>!part||part==="."||part===".."||part.length>128))throw TypeError("Unsafe artifact entry path")}function detach(entries,manifestJson,options){const list=dataList(entries);if(typeof manifestJson!=="string"||manifestJson.length>completedArtifactLimits.manifestBytes)throw RangeError("Artifact resource limit");const manifestSize=Buffer.byteLength(manifestJson,"utf8");if(manifestSize>completedArtifactLimits.manifestBytes)throw RangeError("Completion manifest byte limit");let parsed;try{parsed=JSON.parse(manifestJson)}catch{throw TypeError("Invalid completion manifest JSON")}if(!parsed||typeof parsed!=="object"||Array.isArray(parsed))throw TypeError("Completion manifest must be a JSON object");const optionFields=dataRecord(options,["directories"]),explicit=optionFields.directories?.value===void 0?[]:dataList(optionFields.directories.value),paths=new Set(["manifest.json","manifest.pending.json"]),directories=new Map;let total=manifestSize;const inputs=[];for(const entry of list){const fields=dataRecord(entry,["path","contents"]);if(!Object.hasOwn(fields,"path")||!Object.hasOwn(fields,"contents"))throw TypeError("Invalid artifact entry");const path=fields.path.value;entryPath(path);const key=path.toLowerCase();if(paths.has(key))throw TypeError("Duplicate or reserved artifact path");paths.add(key);const contents=fields.contents.value;if(typeof contents==="string"){total+=Buffer.byteLength(contents,"utf8");inputs.push({path,contents})}else{if(!contents||typeof contents!=="object"||types.isProxy(contents)||!types.isUint8Array(contents))throw TypeError("Artifact contents must be text or unshared bytes");const buffer=byteBuffer.call(contents);if(types.isSharedArrayBuffer(buffer))throw TypeError("Artifact contents must be text or unshared bytes");const offset=byteOffset.call(contents),length=byteLength.call(contents);new Uint8Array(buffer,offset,length);total+=length;inputs.push({path,contents:{buffer,offset,length}})}if(total>completedArtifactLimits.bytes)throw RangeError("Artifact byte limit")}function register(path,includeLeaf){const parts=path.split("/");for(let depth=1;depth<=parts.length-(includeLeaf?0:1);depth++){const parent=parts.slice(0,depth).join("/"),key=parent.toLowerCase();if(paths.has(key))throw TypeError("Artifact directory and file collision");if(directories.has(key)&&directories.get(key)!==parent)throw TypeError("Artifact directory case alias collision");directories.set(key,parent)}}for(const path of explicit){entryPath(path);register(path,!0)}for(const input of inputs)register(input.path,!1);const files=inputs.map(({path,contents})=>{const bytes=typeof contents==="string"?new TextEncoder().encode(contents):new Uint8Array(contents.length);if(typeof contents!=="string")setBytes.call(bytes,new Uint8Array(contents.buffer,contents.offset,contents.length));return Object.seal({path,bytes})});return Object.seal({files,directories:[...directories.values()].sort((a,b)=>a.split("/").length-b.split("/").length),manifest:new TextEncoder().encode(manifestJson)})}function identity(path,stat,type){if(!(type==="file"?stat.isFile():stat.isDirectory()))throw Error("Artifact identity has an unexpected type");return Object.freeze({path,type,dev:stat.dev.toString(),ino:stat.ino.toString(),uid:stat.uid.toString(),birthtimeNs:stat.birthtimeNs.toString(),nlink:stat.nlink.toString(),mode:Number(stat.mode&0o7777n)})}function sameIdentity(expected,stat,links=expected.nlink){const actual=identity(expected.path,stat,expected.type);if(actual.dev!==expected.dev||actual.ino!==expected.ino||actual.uid!==expected.uid||actual.birthtimeNs!==expected.birthtimeNs||actual.mode!==expected.mode||actual.nlink!==links)throw Error("Artifact identity changed")}function openObserved(path,type,flags,inspect){const before=lstatSync(path,{bigint:!0}),observed=identity(path,before,type),fd=openSync(path,flags|constants.O_NOFOLLOW|constants.O_NONBLOCK|(type==="directory"?constants.O_DIRECTORY:0));try{const stat=fstatSync(fd,{bigint:!0});sameIdentity(observed,stat);sameIdentity(observed,lstatSync(path,{bigint:!0}));inspect(fd,stat);return observed}finally{closeSync(fd)}}function assertPrivate(observed,uid,device){if(observed.uid!==uid||observed.dev!==device||observed.mode!==(observed.type==="file"?384:448)||observed.type==="file"&&observed.nlink!=="1")throw Error("Artifact must retain private ownership, mode and links")}function verifyWrittenHash(fd,expected){const hash=createHash("sha256"),scratch=new Uint8Array(Math.min(65536,expected.bytes));let offset=0;while(offset<expected.bytes){const read=readSync(fd,scratch,0,Math.min(scratch.byteLength,expected.bytes-offset),offset);if(read<=0)throw Error("Artifact readback made no progress");hash.update(scratch.subarray(0,read));offset+=read}if(hash.digest("hex")!==expected.sha256)throw Error("Artifact written hash changed");if(fstatSync(fd,{bigint:!0}).size!==BigInt(expected.bytes))throw Error("Artifact written length changed")}export function prepareCompletedArtifactDirectorySync(directory,entries,manifestJson,options={}){if(typeof directory!=="string"||directory.length<1||directory.length>4096||directory.includes("\x00"))throw TypeError("Invalid artifact directory");const layout=detach(entries,manifestJson,options);if(!constants.O_NOFOLLOW||!constants.O_NONBLOCK||!constants.O_DIRECTORY||!process.getuid)throw Error("Prepared artifacts require POSIX no-follow, nonblocking and directory opens");const uid=String(process.getuid()),lexicalDirectory=resolve(directory),parentPath=realpathSync(dirname(lexicalDirectory)),target=join(parentPath,basename(lexicalDirectory)),parentBefore=openObserved(parentPath,"directory",constants.O_RDONLY,()=>{});if(parentBefore.uid!==uid)throw Error("Artifact parent must belong to the operator");const device=parentBefore.dev;mkdirSync(target,{mode:448});const createdDirectories=new Map;function captureDirectory(path){const observed=openObserved(path,"directory",constants.O_RDONLY,(fd,stat)=>{assertPrivate(identity(path,stat,"directory"),uid,device);const created=createdDirectories.get(path);if(created)sameIdentity(created,stat,stat.nlink.toString());fsyncSync(fd)});createdDirectories.set(path,observed);return observed}captureDirectory(target);for(const path of layout.directories){captureDirectory(dirname(join(target,path)));mkdirSync(join(target,path),{mode:448});captureDirectory(join(target,path))}function preallocate(path,bytes){captureDirectory(dirname(path));const fd=openSync(path,constants.O_CREAT|constants.O_EXCL|constants.O_RDWR|constants.O_NOFOLLOW|constants.O_NONBLOCK,384);let observed,preparationCtimeNs;try{const stat=fstatSync(fd,{bigint:!0});observed=identity(path,stat,"file");assertPrivate(observed,uid,device);sameIdentity(observed,lstatSync(path,{bigint:!0}));if(stat.size!==0n)throw Error("Artifact preallocation must be empty");fsyncSync(fd);preparationCtimeNs=fstatSync(fd,{bigint:!0}).ctimeNs.toString()}finally{closeSync(fd)}return Object.freeze({...observed,preparationCtimeNs,bytes:bytes.byteLength,sha256:createHash("sha256").update(bytes).digest("hex")})}captureDirectory(target);const files=layout.files.map((file)=>preallocate(join(target,file.path),file.bytes)),pendingManifest=preallocate(join(target,"manifest.pending.json"),layout.manifest),directories=layout.directories.map((path)=>captureDirectory(join(target,path))),root=captureDirectory(target),parent=openObserved(parentPath,"directory",constants.O_RDONLY,(fd,stat)=>{sameIdentity(parentBefore,stat,stat.nlink.toString());fsyncSync(fd)}),completedManifest=join(target,"manifest.json"),receipt=Object.freeze({directory:target,lexicalDirectory,parent,root,directories:Object.freeze(directories),files:Object.freeze(files),pendingManifest,completedManifest});let consumed=!1;function verifyDirectory(expected,changingLinks=!1){openObserved(expected.path,"directory",constants.O_RDONLY,(fd,stat)=>{sameIdentity(expected,stat,changingLinks?stat.nlink.toString():expected.nlink);fsyncSync(fd)})}function verifyFile(expected,size,links="1"){openObserved(expected.path,"file",constants.O_RDONLY,(fd,stat)=>{sameIdentity(expected,stat,links);if(stat.size!==BigInt(size))throw Error("Artifact preallocation length changed");if(size===0&&links==="1"&&stat.ctimeNs.toString()!==expected.preparationCtimeNs)throw Error("Artifact empty preallocation changed");if(size>0)verifyWrittenHash(fd,expected)})}function verifyEmptyTree(){if(realpathSync(dirname(lexicalDirectory))!==parentPath)throw Error("Artifact configured parent changed");verifyDirectory(parent,!0);verifyDirectory(root);for(const directory of directories)verifyDirectory(directory);const expectedChildren=new Map([root,...directories].map((item)=>[item.path,[]]));for(const item of[...directories,...files,pendingManifest])expectedChildren.get(dirname(item.path)).push(basename(item.path));for(const[path,children]of expectedChildren){const actual=readdirSync(path).sort(),expected=children.sort();if(actual.length!==expected.length||actual.some((name,index)=>name!==expected[index]))throw Error("Artifact directory entries changed")}for(const file of files)verifyFile(file,0);verifyFile(pendingManifest,0)}function writeDetached(expected,bytes){openObserved(expected.path,"file",constants.O_RDWR,(fd,stat)=>{sameIdentity(expected,stat);if(stat.size!==0n)throw Error("Artifact preallocation length changed");if(stat.ctimeNs.toString()!==expected.preparationCtimeNs)throw Error("Artifact empty preallocation changed");let offset=0;while(offset<bytes.byteLength){const written=writeSync(fd,bytes,offset,bytes.byteLength-offset,offset);if(written<=0)throw Error("Artifact write made no progress");offset+=written}fsyncSync(fd);sameIdentity(expected,fstatSync(fd,{bigint:!0}));if(fstatSync(fd,{bigint:!0}).size!==BigInt(expected.bytes))throw Error("Artifact write length changed");verifyWrittenHash(fd,expected)})}return Object.freeze({receipt,publishSync:()=>{if(consumed)throw Error("Prepared artifact handle is consumed");consumed=!0;try{verifyEmptyTree();for(let index=0;index<files.length;index++)writeDetached(files[index],layout.files[index].bytes);for(const directory of[...directories].reverse())verifyDirectory(directory);verifyDirectory(root);verifyDirectory(parent,!0);writeDetached(pendingManifest,layout.manifest);linkSync(pendingManifest.path,completedManifest);verifyFile(pendingManifest,pendingManifest.bytes,"2");verifyFile({...pendingManifest,path:completedManifest},pendingManifest.bytes,"2");verifyDirectory(root,!0);unlinkSync(pendingManifest.path);verifyFile({...pendingManifest,path:completedManifest},pendingManifest.bytes);verifyDirectory(root);return{directory:target,manifest:completedManifest}}finally{for(const file of layout.files)file.bytes=new Uint8Array(0);layout.manifest=new Uint8Array(0)}}})}
@@ -1,4 +1,6 @@
1
1
  import process from 'node:process';
2
+ /** Inspect an existing canonical directory without requiring process quiescence. */
3
+ export declare function inspectDirectoryMounts(path: string): DirectoryMountInspection;
2
4
  /** Report missing inspection permission explicitly; never infer that an unreadable process is idle. */
3
5
  export declare function inspectTemporaryWorkspace(path: string): TemporaryWorkspaceInspection;
4
6
  export declare function temporaryWorkspaceIsUnused(path: string): boolean;
@@ -23,6 +25,13 @@ export declare interface TemporaryWorkspaceRetirementInspection {
23
25
  record?: TemporaryWorkspaceRecord
24
26
  usage?: TemporaryWorkspaceInspection
25
27
  }
28
+ /** Own-namespace mount observations only. This does not inspect readers or authorize retirement. */
29
+ export declare interface DirectoryMountInspection {
30
+ unmounted: boolean
31
+ reason: 'unmounted' | 'mounted' | 'inspection-unavailable' | 'unsupported-platform'
32
+ operation?: 'path' | 'mounts'
33
+ code?: string
34
+ }
26
35
  /** Recoverable directories with separate live ownership and crash-recovery lifecycles. */
27
36
  export declare class TemporaryWorkspaceJournal {
28
37
  readonly directory: string;
@@ -1,4 +1,4 @@
1
1
  import{existsSync,lstatSync,mkdirSync,mkdtempSync,readFileSync,readdirSync,readlinkSync,realpathSync,renameSync,rmSync,writeFileSync}from"node:fs";import{basename,dirname,join,resolve}from"node:path";import process from"node:process";function vanished(error){return error instanceof Error&&"code"in error&&(error.code==="ENOENT"||error.code==="ESRCH")}function beneath(path,root){const clean=path.replace(/ \(deleted\)$/,"");return clean===root||clean.startsWith(`${root}/`)}function mountedWorkspace(table,path){return table.split(`
2
2
  `).some((line)=>{const fields=line.split(" ");return[fields[3],fields[4]].some((field)=>field&&beneath(field.replace(/\\([0-7]{3})/g,(_match,octal)=>String.fromCharCode(Number.parseInt(octal,8))),path))})}function inspectWorkspaceMounts(path){try{if(process.platform==="linux"){const table=readFileSync("/proc/self/mountinfo","utf8");if(!table.trim())return{unused:!1,reason:"inspection-unavailable",operation:"mounts"};const mounted=mountedWorkspace(table,path);return{unused:!mounted,reason:mounted?"mounted":"unused"}}if(process.platform==="darwin"){const mounts=Bun.spawnSync(["mount"],{stdout:"pipe",stderr:"pipe"});if(mounts.exitCode!==0||mounts.stderr.length>0||mounts.stdout.length===0)return{unused:!1,reason:"inspection-unavailable",operation:"mounts"};const mounted=mounts.stdout.toString().split(`
3
- `).some((line)=>{const target=/ on (.+) \(/.exec(line)?.[1];return target!==void 0&&beneath(target,path)});return{unused:!mounted,reason:mounted?"mounted":"unused"}}}catch(error){return{unused:!1,reason:"inspection-unavailable",operation:"mounts",code:error instanceof Error&&"code"in error?String(error.code):void 0}}return{unused:!1,reason:"unsupported-platform"}}export function inspectTemporaryWorkspace(path){let operation="mounts";try{const mounts=inspectWorkspaceMounts(path);if(!mounts.unused)return mounts;if(process.platform==="linux"){const uid=process.getuid?.();for(const pid of readdirSync("/proc").filter((name)=>/^\d+$/.test(name))){const directory=`/proc/${pid}`;try{operation="process-owner";const identities=/^Uid:\s+(.+)$/m.exec(readFileSync(`${directory}/status`,"utf8"))?.[1]?.trim().split(/\s+/).map(Number);if(!identities?.length)return{unused:!1,reason:"inspection-unavailable",pid:Number(pid),operation};if(uid!==0&&!identities.includes(Number(uid)))continue;operation="process-mounts";if(mountedWorkspace(readFileSync(`${directory}/mountinfo`,"utf8"),path))return{unused:!1,reason:"mounted",pid:Number(pid)};operation="process-paths";const references=[readlinkSync(`${directory}/cwd`),readlinkSync(`${directory}/exe`)];operation="process-files";for(const fd of readdirSync(`${directory}/fd`))try{references.push(readlinkSync(`${directory}/fd/${fd}`))}catch(error){if(!vanished(error))throw error}operation="process-maps";for(const line of readFileSync(`${directory}/maps`,"utf8").split(`
3
+ `).some((line)=>{const target=/ on (.+) \(/.exec(line)?.[1];return target!==void 0&&beneath(target,path)});return{unused:!mounted,reason:mounted?"mounted":"unused"}}}catch(error){return{unused:!1,reason:"inspection-unavailable",operation:"mounts",code:error instanceof Error&&"code"in error?String(error.code):void 0}}return{unused:!1,reason:"unsupported-platform"}}export function inspectDirectoryMounts(path){try{if(typeof path!=="string"||path.length===0||path.length>4096||path.includes("\x00")||realpathSync(path)!==path||!lstatSync(path).isDirectory())return{unmounted:!1,reason:"inspection-unavailable",operation:"path",code:"INVALID_DIRECTORY"}}catch(error){return{unmounted:!1,reason:"inspection-unavailable",operation:"path",code:error instanceof Error&&"code"in error?String(error.code):void 0}}const result=inspectWorkspaceMounts(path);if(result.reason==="unused")return{unmounted:!0,reason:"unmounted"};if(result.reason==="mounted")return{unmounted:!1,reason:"mounted"};if(result.reason==="unsupported-platform")return{unmounted:!1,reason:"unsupported-platform"};return{unmounted:!1,reason:"inspection-unavailable",operation:"mounts",code:result.code}}export function inspectTemporaryWorkspace(path){let operation="mounts";try{const mounts=inspectWorkspaceMounts(path);if(!mounts.unused)return mounts;if(process.platform==="linux"){const uid=process.getuid?.();for(const pid of readdirSync("/proc").filter((name)=>/^\d+$/.test(name))){const directory=`/proc/${pid}`;try{operation="process-owner";const identities=/^Uid:\s+(.+)$/m.exec(readFileSync(`${directory}/status`,"utf8"))?.[1]?.trim().split(/\s+/).map(Number);if(!identities?.length)return{unused:!1,reason:"inspection-unavailable",pid:Number(pid),operation};if(uid!==0&&!identities.includes(Number(uid)))continue;operation="process-mounts";if(mountedWorkspace(readFileSync(`${directory}/mountinfo`,"utf8"),path))return{unused:!1,reason:"mounted",pid:Number(pid)};operation="process-paths";const references=[readlinkSync(`${directory}/cwd`),readlinkSync(`${directory}/exe`)];operation="process-files";for(const fd of readdirSync(`${directory}/fd`))try{references.push(readlinkSync(`${directory}/fd/${fd}`))}catch(error){if(!vanished(error))throw error}operation="process-maps";for(const line of readFileSync(`${directory}/maps`,"utf8").split(`
4
4
  `)){const mapped=/^\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(.+)$/.exec(line)?.[1];if(mapped)references.push(mapped)}if(references.some((reference)=>beneath(reference,path)))return{unused:!1,reason:"process-reference",pid:Number(pid)}}catch(error){if(!vanished(error))return{unused:!1,reason:"inspection-unavailable",pid:Number(pid),operation,code:error instanceof Error&&"code"in error?String(error.code):void 0}}}return{unused:!0,reason:"unused"}}if(process.platform==="darwin"){const uid=process.getuid?.();if(uid===void 0)return{unused:!1,reason:"inspection-unavailable",operation:"process-owner"};operation="process-files";const files=Bun.spawnSync(["lsof","-nP","-F0n",...uid===0?[]:["-u",String(uid)]],{stdout:"pipe",stderr:"pipe"});if(files.exitCode!==0||files.stderr.length>0)return{unused:!1,reason:"inspection-unavailable",operation};const used=files.stdout.toString().split("\x00").some((field)=>field.startsWith("n")&&beneath(field.slice(1),path));return{unused:!used,reason:used?"process-reference":"unused"}}}catch(error){return{unused:!1,reason:"inspection-unavailable",operation,code:error instanceof Error&&"code"in error?String(error.code):void 0}}return{unused:!1,reason:"unsupported-platform"}}export function temporaryWorkspaceIsUnused(path){return inspectTemporaryWorkspace(path).unused}export class TemporaryWorkspaceJournal{directory;temporaryRoot;prefix;ownerUid;active=new Map;rootInode;rootDevice;journalInode;journalDevice;constructor(options){if(typeof options.prefix!=="string"||!/^[a-z0-9_-]+$/i.test(options.prefix))throw TypeError("Invalid temporary workspace prefix");const currentUid=process.getuid?.(),ownerUid=options.ownerUid??currentUid;if(currentUid===void 0||ownerUid===void 0||!Number.isSafeInteger(ownerUid)||ownerUid<0)throw Error("Workspace ownership requires a POSIX user identity");if(ownerUid!==currentUid&&currentUid!==0)throw Error("Only root may maintain another user's workspace journal");if(ownerUid!==currentUid&&!existsSync(options.directory))throw Error("Another user's journal must already exist");mkdirSync(options.directory,{recursive:!0,mode:448});const journal=lstatSync(options.directory);if(!journal.isDirectory()||journal.isSymbolicLink()||journal.uid!==ownerUid||(journal.mode&63)!==0)throw Error("A workspace journal must be a private directory owned by its declared user");this.ownerUid=ownerUid;this.directory=realpathSync(options.directory);this.temporaryRoot=realpathSync(options.temporaryRoot);if(this.directory===this.temporaryRoot)throw Error("The journal cannot be the temporary root");this.prefix=options.prefix;const root=lstatSync(this.temporaryRoot,{bigint:!0}),journalIdentity=lstatSync(this.directory,{bigint:!0});this.rootInode=String(root.ino);this.rootDevice=String(root.dev);this.journalInode=String(journalIdentity.ino);this.journalDevice=String(journalIdentity.dev)}create(key){if(typeof key!=="string"||!/^[a-z0-9_-]{1,80}$/i.test(key))throw TypeError("Invalid workspace key");if(this.ownerUid!==process.getuid?.())throw Error("Maintenance cannot create workspaces for another user");if(!this.rootsUnchanged())throw Error("The workspace or journal root changed");const path=mkdtempSync(join(this.temporaryRoot,`${this.prefix}${key}-`)),identity=lstatSync(path,{bigint:!0}),record=Object.freeze({id:crypto.randomUUID(),key,path,inode:String(identity.ino),device:String(identity.dev),uid:String(identity.uid)}),file=join(this.directory,`${record.id}.json`);try{writeFileSync(`${file}.tmp`,JSON.stringify(record),{flag:"wx",mode:384});renameSync(`${file}.tmp`,file);this.active.set(record.id,record);return record}catch(error){rmSync(path,{recursive:!0,force:!0});rmSync(`${file}.tmp`,{force:!0});throw error}}list(){if(!this.rootsUnchanged())return[];const records=[];for(const entry of readdirSync(this.directory).slice(0,1000)){if(!entry.endsWith(".json"))continue;const record=this.read(entry.slice(0,-5));if(record)records.push(record)}return records}complete(record){if(!record||this.active.get(record.id)!==record)return!1;if(!this.remove(record))return!1;this.active.delete(record.id);return!0}async retire(id,mayRetire){const verdict=await this.inspectRetirement(id,mayRetire);if(!verdict.ready||!verdict.record||!this.remove(verdict.record))return!1;this.active.delete(verdict.record.id);return!0}async inspectRetirement(id,mayRetire){const record=this.read(id);if(!record)return{ready:!1,reason:"record-invalid"};if(await mayRetire(record)!==!0)return{ready:!1,reason:"not-authorized",record};if(!this.rootsUnchanged())return{ready:!1,reason:"identity-changed",record};const identity=this.identity(record);if(identity==="changed"||identity==="unavailable")return{ready:!1,reason:"identity-changed",record};if(identity==="missing")return{ready:!0,reason:"ready",record};const usage=inspectTemporaryWorkspace(record.path);return{ready:usage.unused,reason:usage.unused?"ready":usage.reason,record,usage}}identity(record){try{const identity=lstatSync(record.path,{bigint:!0});return identity.isDirectory()&&!identity.isSymbolicLink()&&String(identity.uid)===record.uid&&String(identity.uid)===String(this.ownerUid)&&String(identity.ino)===record.inode&&String(identity.dev)===record.device?"same":"changed"}catch(error){return vanished(error)?"missing":"unavailable"}}remove(record){try{if(!this.rootsUnchanged())return!1;const identity=this.identity(record);if(identity==="changed"||identity==="unavailable")return!1;if(identity==="same"){if(!inspectWorkspaceMounts(record.path).unused)return!1;rmSync(record.path,{recursive:!0})}}catch(error){if(!vanished(error)||this.identity(record)!=="missing")return!1}rmSync(join(this.directory,`${record.id}.json`),{force:!0});return!0}read(id){if(typeof id!=="string"||!/^[0-9a-f-]{36}$/.test(id))return null;if(!this.rootsUnchanged())return null;try{const file=join(this.directory,`${id}.json`),identity=lstatSync(file);if(!identity.isFile()||identity.isSymbolicLink()||identity.uid!==this.ownerUid||(identity.mode&63)!==0||identity.size>4096)return null;const value=JSON.parse(readFileSync(file,"utf8"));if(!value||value.id!==id||typeof value.key!=="string"||!/^[a-z0-9_-]{1,80}$/i.test(value.key)||typeof value.path!=="string"||resolve(value.path)!==value.path||dirname(value.path)!==this.temporaryRoot||!basename(value.path).startsWith(`${this.prefix}${value.key}-`)||!["inode","device","uid"].every((key)=>typeof value[key]==="string"&&/^\d+$/.test(value[key])))return null;return Object.freeze(value)}catch{return null}}rootsUnchanged(){try{const root=lstatSync(this.temporaryRoot,{bigint:!0}),journal=lstatSync(this.directory,{bigint:!0});return root.isDirectory()&&!root.isSymbolicLink()&&String(root.ino)===this.rootInode&&String(root.dev)===this.rootDevice&&journal.isDirectory()&&!journal.isSymbolicLink()&&String(journal.ino)===this.journalInode&&String(journal.dev)===this.journalDevice&&String(journal.uid)===String(this.ownerUid)&&(journal.mode&0o077n)===0n}catch{return!1}}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/storage",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.230",
5
+ "version": "0.75.233",
6
6
  "description": "The Stacks file system.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -61,20 +61,20 @@
61
61
  "prepublishOnly": "bun run build"
62
62
  },
63
63
  "dependencies": {
64
- "@stacksjs/arrays": "0.75.230",
65
- "@stacksjs/cli": "0.75.230",
66
- "@stacksjs/config": "0.75.230",
67
- "@stacksjs/env": "0.75.230",
68
- "@stacksjs/error-handling": "0.75.230",
69
- "@stacksjs/logging": "0.75.230",
70
- "@stacksjs/path": "0.75.230",
71
- "@stacksjs/strings": "0.75.230",
64
+ "@stacksjs/arrays": "0.75.233",
65
+ "@stacksjs/cli": "0.75.233",
66
+ "@stacksjs/config": "0.75.233",
67
+ "@stacksjs/env": "0.75.233",
68
+ "@stacksjs/error-handling": "0.75.233",
69
+ "@stacksjs/logging": "0.75.233",
70
+ "@stacksjs/path": "0.75.233",
71
+ "@stacksjs/strings": "0.75.233",
72
72
  "@stacksjs/ts-cloud": "^0.16.58",
73
73
  "@stacksjs/ts-xml": "^0.1.0",
74
74
  "ts-images": "^0.2.23"
75
75
  },
76
76
  "devDependencies": {
77
- "@stacksjs/types": "0.75.230",
77
+ "@stacksjs/types": "0.75.233",
78
78
  "better-dx": "^0.2.24"
79
79
  }
80
80
  }