@nifrajs/storage 2.2.0 → 2.3.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/README.md +8 -8
- package/dist/capabilities.d.ts +55 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +122 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/conformance.d.ts +1 -1
- package/dist/conformance.js +1 -1
- package/dist/file.d.ts +20 -1
- package/dist/file.d.ts.map +1 -1
- package/dist/file.js +192 -25
- package/dist/file.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/key.d.ts +1 -1
- package/dist/key.js +2 -2
- package/dist/memory.js +1 -1
- package/dist/r2.js +1 -1
- package/dist/types.d.ts +3 -3
- package/dist/types.js +1 -1
- package/package.json +2 -2
- package/src/capabilities.ts +172 -0
- package/src/conformance.ts +1 -1
- package/src/file.ts +194 -23
- package/src/index.ts +8 -1
- package/src/key.ts +2 -2
- package/src/memory.ts +1 -1
- package/src/r2.ts +1 -1
- package/src/types.ts +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @nifrajs/storage
|
|
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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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}
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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.
|
|
4
|
-
"description": "Blob storage mechanics for nifra
|
|
3
|
+
"version": "2.3.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
|
+
}
|
package/src/conformance.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
160
|
-
}
|
|
161
|
-
|
|
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.
|
|
168
|
-
|
|
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.
|
|
190
|
-
this.
|
|
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.
|
|
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
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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}
|
|
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)
|