@nifrajs/storage 2.2.0 → 2.4.0

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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
2
+ * @nifrajs/storage - one blob-storage interface, several adapters. The persistence half of
3
3
  * `@nifrajs/uploads`: `uploads` validates + signs, `storage` puts the bytes somewhere. Adapters cover
4
4
  * dev (memory), a long-running server (local disk), and the edge (Cloudflare R2); implement
5
5
  * {@link StorageAdapter} for S3/GCS/etc. Dependency-free.
@@ -9,6 +9,7 @@
9
9
  * await storage.put("avatars/u1.png", bytes, { contentType: "image/png" })
10
10
  * const object = await storage.get("avatars/u1.png")
11
11
  */
12
+ export { type BeaconingStorageAdapter, type CapabilityBeacon, type StorageBeaconOptions, type StorageCapabilities, withCapabilityBeacon, } from "./capabilities.js";
12
13
  export { assertStorageAdapterConformance, StorageAdapterConformanceError, type StorageAdapterConformanceOptions, } from "./conformance.js";
13
14
  export { FileStorage } from "./file.js";
14
15
  export { assertSafeKey, StorageKeyError } from "./key.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EACL,+BAA+B,EAC/B,8BAA8B,EAC9B,KAAK,gCAAgC,GACtC,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACzE,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC1B,OAAO,GACR,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EACL,KAAK,uBAAuB,EAC5B,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,oBAAoB,GACrB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,+BAA+B,EAC/B,8BAA8B,EAC9B,KAAK,gCAAgC,GACtC,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACzE,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,yBAAyB,EAC9B,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC1B,OAAO,GACR,MAAM,YAAY,CAAA"}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
2
+ * @nifrajs/storage - one blob-storage interface, several adapters. The persistence half of
3
3
  * `@nifrajs/uploads`: `uploads` validates + signs, `storage` puts the bytes somewhere. Adapters cover
4
4
  * dev (memory), a long-running server (local disk), and the edge (Cloudflare R2); implement
5
5
  * {@link StorageAdapter} for S3/GCS/etc. Dependency-free.
@@ -9,6 +9,7 @@
9
9
  * await storage.put("avatars/u1.png", bytes, { contentType: "image/png" })
10
10
  * const object = await storage.get("avatars/u1.png")
11
11
  */
12
+ export { withCapabilityBeacon, } from "./capabilities.js";
12
13
  export { assertStorageAdapterConformance, StorageAdapterConformanceError, } from "./conformance.js";
13
14
  export { FileStorage } from "./file.js";
14
15
  export { assertSafeKey, StorageKeyError } from "./key.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EACL,+BAA+B,EAC/B,8BAA8B,GAE/B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAwC,SAAS,EAAE,MAAM,SAAS,CAAA;AACzE,OAAO,EAcL,OAAO,GACR,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAKL,oBAAoB,GACrB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,+BAA+B,EAC/B,8BAA8B,GAE/B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAwC,SAAS,EAAE,MAAM,SAAS,CAAA;AACzE,OAAO,EAcL,OAAO,GACR,MAAM,YAAY,CAAA"}
package/dist/key.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Storage-key safety. A key is a POSIX-ish relative path (`avatars/u1.png`); we reject anything that
3
- * could escape a `FileStorage` root or otherwise misbehave — absolute paths, `..` traversal, NUL bytes,
3
+ * could escape a `FileStorage` root or otherwise misbehave - absolute paths, `..` traversal, NUL bytes,
4
4
  * and backslashes (Windows traversal). Enforced by EVERY adapter (not just `FileStorage`) so a key is
5
5
  * portable across them, and so the check can't be forgotten on the one adapter where it's a vulnerability.
6
6
  */
package/dist/key.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Storage-key safety. A key is a POSIX-ish relative path (`avatars/u1.png`); we reject anything that
3
- * could escape a `FileStorage` root or otherwise misbehave — absolute paths, `..` traversal, NUL bytes,
3
+ * could escape a `FileStorage` root or otherwise misbehave - absolute paths, `..` traversal, NUL bytes,
4
4
  * and backslashes (Windows traversal). Enforced by EVERY adapter (not just `FileStorage`) so a key is
5
5
  * portable across them, and so the check can't be forgotten on the one adapter where it's a vulnerability.
6
6
  */
@@ -13,7 +13,7 @@ export class StorageKeyError extends Error {
13
13
  /** Throw {@link StorageKeyError} unless `key` is a safe relative storage key. */
14
14
  export function assertSafeKey(key) {
15
15
  if (key.length === 0 || key.length > 1024) {
16
- throw new StorageKeyError(`storage key must be 1–1024 chars (got ${key.length})`);
16
+ throw new StorageKeyError(`storage key must be 1-1024 chars (got ${key.length})`);
17
17
  }
18
18
  if (key.startsWith("/") || key.includes("\\") || key.includes("\0")) {
19
19
  throw new StorageKeyError(`unsafe storage key ${JSON.stringify(key)} (absolute, backslash, or NUL)`);
package/dist/memory.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * In-process {@link StorageAdapter} — a Map of key → bytes + metadata. For dev and tests; not durable
2
+ * In-process {@link StorageAdapter} - a Map of key → bytes + metadata. For dev and tests; not durable
3
3
  * (a restart empties it) and not shared across instances. Methods are `async` so an unsafe key REJECTS
4
4
  * (consistent with the disk/R2 adapters), never throws synchronously.
5
5
  */
package/dist/r2.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Cloudflare R2 {@link StorageAdapter} — wraps an R2 bucket binding. The binding is typed structurally
2
+ * Cloudflare R2 {@link StorageAdapter} - wraps an R2 bucket binding. The binding is typed structurally
3
3
  * (the slice we use), so this stays dependency-free: no `@cloudflare/workers-types`. Pass `env.MY_BUCKET`.
4
4
  *
5
5
  * const storage = new R2Storage(env.UPLOADS)
package/dist/types.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
2
+ * @nifrajs/storage - one blob-storage interface, several adapters. The persistence half of
3
3
  * `@nifrajs/uploads` (which validates + signs but doesn't store): `uploads` decides what's allowed,
4
4
  * `storage` puts the bytes somewhere. Adapters: {@link MemoryStorage} (dev/test), `FileStorage` (local
5
5
  * disk), `R2Storage` (Cloudflare R2). Bring your own for S3/GCS by implementing {@link StorageAdapter}.
6
6
  */
7
- /** Accepted `put` payloads — normalized to bytes by each adapter. */
7
+ /** Accepted `put` payloads - normalized to bytes by each adapter. */
8
8
  export type StorageData = Uint8Array | ArrayBuffer | string;
9
9
  export interface PutOptions {
10
10
  /** MIME type stored alongside the object (returned by `get`). */
@@ -12,7 +12,7 @@ export interface PutOptions {
12
12
  /** Arbitrary string metadata stored with the object. */
13
13
  readonly metadata?: Readonly<Record<string, string>>;
14
14
  }
15
- /** An object read back from storage. `body` is buffered (not streamed) — fine for typical uploads. */
15
+ /** An object read back from storage. `body` is buffered (not streamed) - fine for typical uploads. */
16
16
  export interface StorageObject {
17
17
  readonly body: Uint8Array;
18
18
  readonly size: number;
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
2
+ * @nifrajs/storage - one blob-storage interface, several adapters. The persistence half of
3
3
  * `@nifrajs/uploads` (which validates + signs but doesn't store): `uploads` decides what's allowed,
4
4
  * `storage` puts the bytes somewhere. Adapters: {@link MemoryStorage} (dev/test), `FileStorage` (local
5
5
  * disk), `R2Storage` (Cloudflare R2). Bring your own for S3/GCS by implementing {@link StorageAdapter}.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nifrajs/storage",
3
- "version": "2.2.0",
4
- "description": "Blob storage mechanics for nifra — traversal-safe memory, filesystem, and R2 adapters, optional provider capabilities, and executable adapter conformance.",
3
+ "version": "2.4.0",
4
+ "description": "Blob storage mechanics for nifra - traversal-safe memory, filesystem, and R2 adapters, optional provider capabilities, and executable adapter conformance.",
5
5
  "keywords": [
6
6
  "bun",
7
7
  "typescript",
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Capability beacons for a storage adapter.
3
+ *
4
+ * `StorageAdapter` is meant to be implemented outside this package - that is the point of the
5
+ * interface - so a `for(context)` METHOD on it would break every adapter anyone has written. This is a
6
+ * wrapper instead: it takes any adapter and hands back one that announces what it is doing.
7
+ *
8
+ * import { useCapability } from "@nifrajs/core/capabilities"
9
+ * import { R2Storage, withCapabilityBeacon } from "@nifrajs/storage"
10
+ *
11
+ * const storage = withCapabilityBeacon(new R2Storage(env.BUCKET), { beacon: useCapability })
12
+ * await storage.for(c).put(key, bytes) // announces `storage.write` first
13
+ *
14
+ * Evidence from the CALL is per-route and exact. Evidence from an import is per-module, so it is as
15
+ * broad as the module holding it - which is why a beacon can say something a provenance rule cannot.
16
+ */
17
+ import type { StorageAdapter } from "./types.ts"
18
+
19
+ /**
20
+ * `useCapability` from `@nifrajs/core/capabilities`, taken as a parameter rather than imported so this
21
+ * package keeps its zero dependencies.
22
+ */
23
+ export type CapabilityBeacon = (context: object, capability: string) => void
24
+
25
+ /** Tokens the wrapper announces. Defaults: `storage.read` and `storage.write`. */
26
+ export interface StorageCapabilities {
27
+ readonly read?: string
28
+ readonly write?: string
29
+ }
30
+
31
+ export interface StorageBeaconOptions {
32
+ readonly beacon: CapabilityBeacon
33
+ readonly capabilities?: StorageCapabilities
34
+ }
35
+
36
+ /**
37
+ * Which capability each method announces.
38
+ *
39
+ * The five core methods plus the optional extensions an adapter may implement (`listPage` from
40
+ * `PagedStorageAdapter`, `copy`/`move` from `MovableStorageAdapter`). `presign` is absent on purpose:
41
+ * its token depends on the `operation` argument, since a PUT URL hands out write access.
42
+ */
43
+ const ACCESS: Readonly<Record<string, "read" | "write">> = {
44
+ get: "read",
45
+ exists: "read",
46
+ list: "read",
47
+ listPage: "read",
48
+ put: "write",
49
+ delete: "write",
50
+ copy: "write",
51
+ move: "write",
52
+ }
53
+
54
+ /**
55
+ * A storage adapter that can be bound to a request context.
56
+ *
57
+ * Generic over the adapter, so wrapping a `PresignableStorageAdapter` gives back something that still
58
+ * presigns. The first version returned a plain `StorageAdapter` assembled from five hand-listed
59
+ * methods, which silently dropped `presign`, `listPage`, `copy` and `move` - so adding beacons to a
60
+ * certified S3 adapter quietly removed every optional capability it was certified for.
61
+ */
62
+ export type BeaconingStorageAdapter<A extends StorageAdapter = StorageAdapter> = A & {
63
+ /**
64
+ * A view bound to a request context: every operation announces its capability first, and fails
65
+ * closed when the route did not declare it.
66
+ */
67
+ for(context: object): A
68
+ }
69
+
70
+ /**
71
+ * Wrap an adapter so `for(context)` announces each operation's capability.
72
+ *
73
+ * Forwarding is by Proxy rather than by enumeration, and that is the whole point: a wrapper that lists
74
+ * the methods it knows about is a wrapper that silently deletes the ones it does not, and an adapter's
75
+ * optional capabilities are precisely the methods a hand-written list forgets.
76
+ */
77
+ export function withCapabilityBeacon<A extends StorageAdapter>(
78
+ adapter: A,
79
+ options: StorageBeaconOptions,
80
+ ): BeaconingStorageAdapter<A> {
81
+ if (typeof options.beacon !== "function") {
82
+ throw new TypeError("@nifrajs/storage: withCapabilityBeacon needs a beacon function")
83
+ }
84
+ const read = options.capabilities?.read ?? "storage.read"
85
+ const write = options.capabilities?.write ?? "storage.write"
86
+
87
+ /**
88
+ * The token a call announces. `presign` reads its `operation` argument, because minting a PUT URL
89
+ * hands the holder write access to the bucket and must not be evidenced as a read.
90
+ *
91
+ * An unrecognised method announces WRITE. A declaration describes what a route MAY do, so the
92
+ * conservative answer is the correct one - an adapter extension nobody mapped should fail closed
93
+ * against a read-only route rather than slip through unannounced.
94
+ */
95
+ const tokenFor = (method: string, args: readonly unknown[]): string => {
96
+ if (method === "presign") return args[1] === "put" ? write : read
97
+ return ACCESS[method] === "read" ? read : write
98
+ }
99
+
100
+ /**
101
+ * Proxy an extensible facade, never the adapter itself. A Proxy may not return a different value for
102
+ * a frozen non-writable method property; targeting the adapter therefore throws an invariant error
103
+ * before our wrapper can run. The facade preserves the prototype for `instanceof`, while every read,
104
+ * write and call still delegates to the real instance (including `#private` brands).
105
+ */
106
+ const facade = (read: (prop: PropertyKey) => unknown, hasFor: boolean): A => {
107
+ const target = Object.create(Object.getPrototypeOf(adapter)) as object
108
+ return new Proxy(target, {
109
+ get: (_target, prop) => read(prop),
110
+ has: (_target, prop) => (hasFor && prop === "for") || Reflect.has(adapter, prop),
111
+ ownKeys: () => Reflect.ownKeys(adapter),
112
+ getOwnPropertyDescriptor: (_target, prop) => {
113
+ const descriptor = Reflect.getOwnPropertyDescriptor(adapter, prop)
114
+ if (descriptor === undefined) return undefined
115
+ // The facade is extensible, so expose a configurable data view without copying a frozen
116
+ // descriptor onto the proxy target (which would recreate the invariant problem).
117
+ return {
118
+ configurable: true,
119
+ enumerable: descriptor.enumerable ?? false,
120
+ writable: true,
121
+ value: read(prop),
122
+ }
123
+ },
124
+ set: (_target, prop, value) => Reflect.set(adapter, prop, value, adapter),
125
+ }) as A
126
+ }
127
+
128
+ // Built per call rather than cached per context: a map keyed by context would hold every request's
129
+ // context for the process lifetime, which is a leak with a request attached to it.
130
+ const bind = (context: object): A => {
131
+ const methods = new Map<PropertyKey, unknown>()
132
+ return facade((prop) => {
133
+ const value = Reflect.get(adapter, prop, adapter)
134
+ if (typeof value !== "function" || typeof prop !== "string") return value
135
+ const cached = methods.get(prop)
136
+ if (cached !== undefined) return cached
137
+ const wrapped = (...args: unknown[]): unknown => {
138
+ // A refused capability surfaces as a REJECTION, not a synchronous throw: these methods return
139
+ // promises, so a caller reaching for `.catch(…)` rather than `try` would otherwise miss it.
140
+ try {
141
+ options.beacon(context, tokenFor(prop, args))
142
+ } catch (error) {
143
+ return Promise.reject(error)
144
+ }
145
+ return Reflect.apply(value as (...a: unknown[]) => unknown, adapter, args)
146
+ }
147
+ methods.set(prop, wrapped)
148
+ return wrapped
149
+ }, false)
150
+ }
151
+
152
+ // The unbound adapter keeps working exactly as before, including for methods this module has never
153
+ // heard of. Only the `for(...)` path announces anything.
154
+ //
155
+ // Methods come back bound to the target for the same reason the getter reads against it. They are
156
+ // cached per wrapper, so repeated property reads preserve identity and allocate once.
157
+ //
158
+ // An adapter with its own `for` method is shadowed by the wrapper's. That is a genuine (if unlikely)
159
+ // collision, and `for` is the name the sibling beacons already use, so it stays consistent rather
160
+ // than novel.
161
+ const methods = new Map<PropertyKey, unknown>()
162
+ return facade((prop) => {
163
+ if (prop === "for") return bind
164
+ const value = Reflect.get(adapter, prop, adapter)
165
+ if (typeof value !== "function") return value
166
+ const cached = methods.get(prop)
167
+ if (cached !== undefined) return cached
168
+ const bound = value.bind(adapter)
169
+ methods.set(prop, bound)
170
+ return bound
171
+ }, true) as BeaconingStorageAdapter<A>
172
+ }
@@ -46,7 +46,7 @@ const hasMove = (storage: StorageAdapter): storage is MovableStorageAdapter =>
46
46
  * only after proving byte + metadata round-trips, overwrite, missing/delete semantics, prefix/limit listing,
47
47
  * and uniform unsafe-key rejection across every key-taking method. When the adapter also implements an
48
48
  * optional capability ({@link PagedStorageAdapter}, {@link PresignableStorageAdapter},
49
- * {@link MovableStorageAdapter}), that capability's contract is proven too — implementing a capability
49
+ * {@link MovableStorageAdapter}), that capability's contract is proven too - implementing a capability
50
50
  * partially or unsafely fails conformance rather than silently escaping it.
51
51
  */
52
52
  export async function assertStorageAdapterConformance(
package/src/file.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * objects created before sidecar support still infer content type from their extension.
6
6
  */
7
7
  import { constants } from "node:fs"
8
- import { lstat, mkdir, open, readdir, readFile, rm, stat } from "node:fs/promises"
9
- import { dirname, join, posix, resolve, sep } from "node:path"
8
+ import { type FileHandle, lstat, mkdir, open, readdir, realpath, unlink } from "node:fs/promises"
9
+ import { basename, dirname, join, posix, resolve, sep } from "node:path"
10
10
  import { assertSafeKey, StorageKeyError } from "./key.ts"
11
11
  import {
12
12
  type ListOptions,
@@ -81,10 +81,54 @@ export class FileStorage implements StorageAdapter {
81
81
  )
82
82
  }
83
83
 
84
+ private assertTrustedDirectory(info: Awaited<ReturnType<typeof lstat>>, key: string): void {
85
+ // Node exposes POSIX mode bits on Bun/Node/Deno Unix runtimes. A group/world-writable ancestor lets
86
+ // another local principal swap path components between otherwise-correct checks; fail closed so
87
+ // descriptor validation only has to defend against ordinary filesystem races within one principal.
88
+ if (process.platform === "win32" || !info.isDirectory()) return
89
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined
90
+ const untrustedOwner = uid !== undefined && info.uid !== uid && info.uid !== 0
91
+ if (untrustedOwner || (Number(info.mode) & 0o022) !== 0) {
92
+ throw new StorageKeyError(
93
+ `storage key ${JSON.stringify(key)} crosses an untrusted writable directory`,
94
+ )
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Prove that another local principal cannot rename any directory leading to `base` after validation.
100
+ * A sticky shared parent (the ordinary `/tmp` case) is safe only while its child belongs to this uid.
101
+ * Root-owned system directories are trusted; a directory owned by any other uid is not.
102
+ */
103
+ private async assertTrustedAncestorChain(base: string, key: string): Promise<void> {
104
+ if (process.platform === "win32" || typeof process.getuid !== "function") return
105
+ const uid = process.getuid()
106
+ let current = await realpath(base)
107
+ let child = await lstat(current)
108
+ for (;;) {
109
+ const parent = dirname(current)
110
+ if (parent === current) return
111
+ const info = await lstat(parent)
112
+ const mode = Number(info.mode)
113
+ const trustedOwner = info.uid === uid || info.uid === 0
114
+ const writableByAnotherPrincipal = (mode & 0o022) !== 0
115
+ const stickyProtectsChild = (mode & 0o1000) !== 0 && child.uid === uid
116
+ if (!trustedOwner || (writableByAnotherPrincipal && !stickyProtectsChild)) {
117
+ throw new StorageKeyError(
118
+ `storage key ${JSON.stringify(key)} crosses an untrusted writable ancestor`,
119
+ )
120
+ }
121
+ current = parent
122
+ child = info
123
+ }
124
+ }
125
+
84
126
  /** Reject existing symbolic links below `base`; missing suffixes are safe for later creation. */
85
127
  private async assertNoSymlinkPath(base: string, path: string, key: string): Promise<void> {
86
128
  try {
87
- if ((await lstat(base)).isSymbolicLink()) throw this.symlinkError(key)
129
+ const info = await lstat(base)
130
+ if (info.isSymbolicLink()) throw this.symlinkError(key)
131
+ this.assertTrustedDirectory(info, key)
88
132
  } catch (error) {
89
133
  if (error instanceof StorageKeyError) throw error
90
134
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return
@@ -95,7 +139,9 @@ export class FileStorage implements StorageAdapter {
95
139
  for (const segment of suffix.split(sep)) {
96
140
  current = join(current, segment)
97
141
  try {
98
- if ((await lstat(current)).isSymbolicLink()) throw this.symlinkError(key)
142
+ const info = await lstat(current)
143
+ if (info.isSymbolicLink()) throw this.symlinkError(key)
144
+ this.assertTrustedDirectory(info, key)
99
145
  } catch (error) {
100
146
  if (error instanceof StorageKeyError) throw error
101
147
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return
@@ -104,7 +150,71 @@ export class FileStorage implements StorageAdapter {
104
150
  }
105
151
  }
106
152
 
107
- /** Create parents, re-check them, and refuse a final-component symlink at open time. */
153
+ /**
154
+ * Open an existing file without following its final component, then prove the descriptor is the file
155
+ * still reachable through a symlink-free path below the same root inode. Reads use the descriptor,
156
+ * not the pathname checked earlier, closing their check/open race.
157
+ */
158
+ private async openContained(base: string, path: string, key: string): Promise<FileHandle | null> {
159
+ await this.assertNoSymlinkPath(base, path, key)
160
+ let baseBefore: Awaited<ReturnType<typeof lstat>>
161
+ try {
162
+ baseBefore = await lstat(base)
163
+ } catch (error) {
164
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null
165
+ throw error
166
+ }
167
+
168
+ let handle: FileHandle
169
+ try {
170
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
171
+ } catch (error) {
172
+ const code = (error as NodeJS.ErrnoException).code
173
+ if (code === "ENOENT") return null
174
+ if (code === "ELOOP") throw this.symlinkError(key)
175
+ throw error
176
+ }
177
+
178
+ try {
179
+ await this.assertOpenedContained(base, path, key, handle, baseBefore)
180
+ return handle
181
+ } catch (error) {
182
+ await handle.close()
183
+ throw error
184
+ }
185
+ }
186
+
187
+ private async assertOpenedContained(
188
+ base: string,
189
+ path: string,
190
+ key: string,
191
+ handle: FileHandle,
192
+ baseBefore: Awaited<ReturnType<typeof lstat>>,
193
+ ): Promise<void> {
194
+ await this.assertNoSymlinkPath(base, path, key)
195
+ try {
196
+ const [opened, current, baseAfter, resolvedBase, resolvedPath] = await Promise.all([
197
+ handle.stat(),
198
+ lstat(path),
199
+ lstat(base),
200
+ realpath(base),
201
+ realpath(path),
202
+ ])
203
+ const baseChanged = baseBefore.dev !== baseAfter.dev || baseBefore.ino !== baseAfter.ino
204
+ const targetChanged =
205
+ current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino
206
+ const escaped = resolvedPath !== resolvedBase && !resolvedPath.startsWith(resolvedBase + sep)
207
+ if (baseChanged || targetChanged || escaped) throw this.symlinkError(key)
208
+ } catch (error) {
209
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw this.symlinkError(key)
210
+ throw error
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Create parents and open without truncating. The opened inode is validated against the current,
216
+ * fully resolved path before any caller data is written, closing the check/open symlink race.
217
+ */
108
218
  private async writeContained(
109
219
  base: string,
110
220
  path: string,
@@ -115,12 +225,31 @@ export class FileStorage implements StorageAdapter {
115
225
  await mkdir(dirname(path), { recursive: true })
116
226
  await this.assertNoSymlinkPath(base, path, key)
117
227
  try {
228
+ const baseBefore = await lstat(base)
118
229
  const handle = await open(
119
230
  path,
120
- constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
231
+ constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW,
121
232
  0o666,
122
233
  )
123
234
  try {
235
+ // Do not truncate until the descriptor is proven to be the same file currently reachable
236
+ // through a symlink-free path beneath the same storage-root directory.
237
+ await this.assertNoSymlinkPath(base, path, key)
238
+ const [opened, current, baseAfter, resolvedBase, resolvedPath] = await Promise.all([
239
+ handle.stat(),
240
+ lstat(path),
241
+ lstat(base),
242
+ realpath(base),
243
+ realpath(path),
244
+ ])
245
+ const baseChanged = baseBefore.dev !== baseAfter.dev || baseBefore.ino !== baseAfter.ino
246
+ const targetChanged =
247
+ current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino
248
+ const escaped =
249
+ resolvedPath !== resolvedBase && !resolvedPath.startsWith(resolvedBase + sep)
250
+ if (baseChanged || targetChanged || escaped) throw this.symlinkError(key)
251
+
252
+ await handle.truncate(0)
124
253
  await handle.writeFile(data)
125
254
  } finally {
126
255
  await handle.close()
@@ -146,26 +275,31 @@ export class FileStorage implements StorageAdapter {
146
275
  }),
147
276
  )
148
277
  } else {
149
- await this.assertNoSymlinkPath(this.metadataRoot, metadataPath, key)
150
- await rm(metadataPath, { force: true })
278
+ await this.removeContained(this.metadataRoot, metadataPath, key)
151
279
  }
152
280
  }
153
281
 
154
282
  async get(key: string): Promise<StorageObject | null> {
155
283
  const path = this.pathFor(key)
156
- await this.assertNoSymlinkPath(this.root, path, key)
284
+ const bodyHandle = await this.openContained(this.root, path, key)
285
+ if (bodyHandle === null) return null
157
286
  let body: Uint8Array
158
287
  try {
159
- body = new Uint8Array(await readFile(path))
160
- } catch (error) {
161
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null
162
- throw error
288
+ body = new Uint8Array(await bodyHandle.readFile())
289
+ } finally {
290
+ await bodyHandle.close()
163
291
  }
164
292
  let stored: { contentType?: string; metadata?: Readonly<Record<string, string>> } = {}
165
293
  try {
166
294
  const metadataPath = this.metadataPathFor(key)
167
- await this.assertNoSymlinkPath(this.metadataRoot, metadataPath, key)
168
- stored = JSON.parse(await readFile(metadataPath, "utf8")) as typeof stored
295
+ const metadataHandle = await this.openContained(this.metadataRoot, metadataPath, key)
296
+ if (metadataHandle !== null) {
297
+ try {
298
+ stored = JSON.parse(await metadataHandle.readFile({ encoding: "utf8" })) as typeof stored
299
+ } finally {
300
+ await metadataHandle.close()
301
+ }
302
+ }
169
303
  } catch (error) {
170
304
  if (error instanceof StorageKeyError) throw error
171
305
  if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) {
@@ -186,21 +320,58 @@ export class FileStorage implements StorageAdapter {
186
320
  const path = this.pathFor(key)
187
321
  const metadataPath = this.metadataPathFor(key)
188
322
  await Promise.all([
189
- this.assertNoSymlinkPath(this.root, path, key),
190
- this.assertNoSymlinkPath(this.metadataRoot, metadataPath, key),
323
+ this.removeContained(this.root, path, key),
324
+ this.removeContained(this.metadataRoot, metadataPath, key),
191
325
  ])
192
- await Promise.all([rm(path, { force: true }), rm(metadataPath, { force: true })])
193
326
  }
194
327
 
195
328
  async exists(key: string): Promise<boolean> {
196
329
  const path = this.pathFor(key)
197
- await this.assertNoSymlinkPath(this.root, path, key)
330
+ const handle = await this.openContained(this.root, path, key)
331
+ if (handle === null) return false
198
332
  try {
199
- await stat(path)
200
333
  return true
201
- } catch (error) {
202
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false
203
- throw error
334
+ } finally {
335
+ await handle.close()
336
+ }
337
+ }
338
+
339
+ /** Revalidate an opened inode immediately before unlinking its pathname. */
340
+ private async removeContained(base: string, path: string, key: string): Promise<void> {
341
+ const handle = await this.openContained(base, path, key)
342
+ if (handle === null) return
343
+ try {
344
+ await this.assertTrustedAncestorChain(base, key)
345
+ const baseBefore = await lstat(base)
346
+ await this.assertOpenedContained(base, path, key, handle, baseBefore)
347
+ if (process.platform === "linux") {
348
+ // Node exposes no unlinkat(2), but Linux's procfs gives an equivalent descriptor-relative path.
349
+ // Anchor the final lookup to an opened, validated parent so replacing an intermediate directory
350
+ // cannot redirect deletion outside the storage root.
351
+ const parentPath = dirname(path)
352
+ const parentHandle = await this.openContained(base, parentPath, key)
353
+ if (parentHandle === null) throw this.symlinkError(key)
354
+ try {
355
+ const anchoredPath = join("/proc/self/fd", String(parentHandle.fd), basename(path))
356
+ const [opened, current] = await Promise.all([handle.stat(), lstat(anchoredPath)])
357
+ if (
358
+ current.isSymbolicLink() ||
359
+ opened.dev !== current.dev ||
360
+ opened.ino !== current.ino
361
+ ) {
362
+ throw this.symlinkError(key)
363
+ }
364
+ await unlink(anchoredPath)
365
+ } finally {
366
+ await parentHandle.close()
367
+ }
368
+ } else {
369
+ // Other Node-compatible runtimes expose no descriptor-relative unlink. The verified ownership /
370
+ // permission chain above removes the cross-principal swap capability before this pathname lookup.
371
+ await unlink(path)
372
+ }
373
+ } finally {
374
+ await handle.close()
204
375
  }
205
376
  }
206
377
 
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
2
+ * @nifrajs/storage - one blob-storage interface, several adapters. The persistence half of
3
3
  * `@nifrajs/uploads`: `uploads` validates + signs, `storage` puts the bytes somewhere. Adapters cover
4
4
  * dev (memory), a long-running server (local disk), and the edge (Cloudflare R2); implement
5
5
  * {@link StorageAdapter} for S3/GCS/etc. Dependency-free.
@@ -10,6 +10,13 @@
10
10
  * const object = await storage.get("avatars/u1.png")
11
11
  */
12
12
 
13
+ export {
14
+ type BeaconingStorageAdapter,
15
+ type CapabilityBeacon,
16
+ type StorageBeaconOptions,
17
+ type StorageCapabilities,
18
+ withCapabilityBeacon,
19
+ } from "./capabilities.ts"
13
20
  export {
14
21
  assertStorageAdapterConformance,
15
22
  StorageAdapterConformanceError,
package/src/key.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Storage-key safety. A key is a POSIX-ish relative path (`avatars/u1.png`); we reject anything that
3
- * could escape a `FileStorage` root or otherwise misbehave — absolute paths, `..` traversal, NUL bytes,
3
+ * could escape a `FileStorage` root or otherwise misbehave - absolute paths, `..` traversal, NUL bytes,
4
4
  * and backslashes (Windows traversal). Enforced by EVERY adapter (not just `FileStorage`) so a key is
5
5
  * portable across them, and so the check can't be forgotten on the one adapter where it's a vulnerability.
6
6
  */
@@ -15,7 +15,7 @@ export class StorageKeyError extends Error {
15
15
  /** Throw {@link StorageKeyError} unless `key` is a safe relative storage key. */
16
16
  export function assertSafeKey(key: string): void {
17
17
  if (key.length === 0 || key.length > 1024) {
18
- throw new StorageKeyError(`storage key must be 1–1024 chars (got ${key.length})`)
18
+ throw new StorageKeyError(`storage key must be 1-1024 chars (got ${key.length})`)
19
19
  }
20
20
  if (key.startsWith("/") || key.includes("\\") || key.includes("\0")) {
21
21
  throw new StorageKeyError(
package/src/memory.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * In-process {@link StorageAdapter} — a Map of key → bytes + metadata. For dev and tests; not durable
2
+ * In-process {@link StorageAdapter} - a Map of key → bytes + metadata. For dev and tests; not durable
3
3
  * (a restart empties it) and not shared across instances. Methods are `async` so an unsafe key REJECTS
4
4
  * (consistent with the disk/R2 adapters), never throws synchronously.
5
5
  */
package/src/r2.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Cloudflare R2 {@link StorageAdapter} — wraps an R2 bucket binding. The binding is typed structurally
2
+ * Cloudflare R2 {@link StorageAdapter} - wraps an R2 bucket binding. The binding is typed structurally
3
3
  * (the slice we use), so this stays dependency-free: no `@cloudflare/workers-types`. Pass `env.MY_BUCKET`.
4
4
  *
5
5
  * const storage = new R2Storage(env.UPLOADS)