@needmoretruth/nmts-cli 0.36.3 → 0.38.1

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.
Files changed (54) hide show
  1. package/AGENTS.md +22 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/dist/artifact-about.d.ts +1 -1
  6. package/dist/commands/erase.js +1 -1
  7. package/dist/commands/organise.d.ts +5 -27
  8. package/dist/commands/organise.js +33 -193
  9. package/dist/commands/push.js +1 -1
  10. package/dist/commands/s3.d.ts +0 -10
  11. package/dist/commands/s3.js +46 -121
  12. package/dist/commands/trash.d.ts +0 -9
  13. package/dist/commands/trash.js +23 -223
  14. package/dist/drive-edit/errors.d.ts +44 -0
  15. package/dist/drive-edit/errors.js +65 -0
  16. package/dist/drive-edit/folders.d.ts +29 -0
  17. package/dist/drive-edit/folders.js +98 -0
  18. package/dist/drive-edit/move.d.ts +66 -0
  19. package/dist/drive-edit/move.js +119 -0
  20. package/dist/drive-edit/trash.d.ts +53 -0
  21. package/dist/drive-edit/trash.js +190 -0
  22. package/dist/drive-edit/tree.d.ts +15 -0
  23. package/dist/drive-edit/tree.js +77 -0
  24. package/dist/drive-edit.d.ts +9 -0
  25. package/dist/drive-edit.js +23 -0
  26. package/dist/product.d.ts +1 -1
  27. package/dist/product.js +1 -1
  28. package/dist/s3/contract.d.ts +80 -0
  29. package/dist/s3/contract.js +3 -0
  30. package/dist/s3/drive.d.ts +108 -0
  31. package/dist/s3/drive.js +136 -0
  32. package/dist/s3/listing.d.ts +8 -1
  33. package/dist/s3/listing.js +8 -1
  34. package/dist/s3/routes.d.ts +4 -0
  35. package/dist/s3/routes.js +249 -0
  36. package/dist/s3/server.d.ts +15 -53
  37. package/dist/s3/server.js +18 -222
  38. package/dist/s3/sigv4.d.ts +26 -0
  39. package/dist/s3/sigv4.js +37 -0
  40. package/dist/s3/xml.d.ts +8 -1
  41. package/dist/s3/xml.js +13 -4
  42. package/dist/s3-gateway.d.ts +8 -0
  43. package/dist/s3-gateway.js +13 -0
  44. package/docs/commands/create.md +2 -2
  45. package/docs/commands/env.md +1 -1
  46. package/docs/commands/extend.md +0 -3
  47. package/docs/commands/login.md +1 -1
  48. package/docs/commands/logout.md +1 -1
  49. package/docs/commands/marks.md +1 -1
  50. package/docs/commands/mcp.md +1 -1
  51. package/docs/commands/put.md +2 -2
  52. package/docs/commands/wallet.md +7 -9
  53. package/docs/commands/whoami.md +2 -2
  54. package/package.json +9 -1
package/dist/product.d.ts CHANGED
@@ -9,7 +9,7 @@ export declare const BINARY_NAME = "nmts";
9
9
  * beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
10
10
  * command importing the entry point is a cycle waiting to bite.
11
11
  */
12
- export declare const VERSION = "0.36.3";
12
+ export declare const VERSION = "0.38.1";
13
13
  /** Where the product lives, for messages that need to send somebody somewhere real. */
14
14
  export declare const HOME_URL = "https://nmts.me";
15
15
  /** The source, so a person holding only the built program can find what it was built from. */
package/dist/product.js CHANGED
@@ -18,7 +18,7 @@ export const BINARY_NAME = "nmts";
18
18
  * beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
19
19
  * command importing the entry point is a cycle waiting to bite.
20
20
  */
21
- export const VERSION = "0.36.3";
21
+ export const VERSION = "0.38.1";
22
22
  /** Where the product lives, for messages that need to send somebody somewhere real. */
23
23
  export const HOME_URL = "https://nmts.me";
24
24
  /** The source, so a person holding only the built program can find what it was built from. */
@@ -0,0 +1,80 @@
1
+ import type { Readable } from "node:stream";
2
+ import type { PlaintextSink } from "../download-sink.ts";
3
+ import type { ManifestEntry } from "../shared/lib/drive/manifest-codec.ts";
4
+ import type { DriveObject } from "./listing.ts";
5
+ import type { GatewayCredential } from "./sigv4.ts";
6
+ export interface DriveSource {
7
+ /** The account's live file list. Called per request; the caller decides what to cache. */
8
+ entries(): Promise<readonly ManifestEntry[]>;
9
+ /**
10
+ * Fetch, decrypt and deliver one file into the sink.
11
+ *
12
+ * ⛔ INJECTED RATHER THAN IMPORTED so this server can be driven by a real S3 client in a test
13
+ * without an account, a network and somebody's credits. A gateway whose only test is an
14
+ * end-to-end one is a gateway whose refusals are never tested at all.
15
+ */
16
+ fetch(object: DriveObject, sink: PlaintextSink): Promise<void>;
17
+ /**
18
+ * How to change the drive, when this machine has agreed to spending.
19
+ *
20
+ * ⛔ ABSENT MEANS READ ONLY, AND THAT IS A REFUSAL RATHER THAN A GAP. Uploading spends credits,
21
+ * which is one of the three things this tool asks a person about once per machine, and a
22
+ * gateway cannot ask: its stdin is not a terminal and the caller is a program. So the
23
+ * agreement has to exist beforehand, and where it does not, every write says so.
24
+ */
25
+ readonly write?: DriveWriter;
26
+ }
27
+ export interface DriveWriter {
28
+ /** Store `body` at this key. `size` is the byte count the client declared. */
29
+ put(key: string, body: Readable, size: number): Promise<void>;
30
+ /** Send one file to the trash, where it stays recoverable for thirty days. */
31
+ trash(object: DriveObject): Promise<void>;
32
+ /**
33
+ * Staging for uploads that arrive in pieces. Absent means this gateway refuses them.
34
+ *
35
+ * ⚠ Separate from `put` because the pieces have to land somewhere before they are one file, and
36
+ * where that is belongs to whoever is running this rather than to the protocol.
37
+ */
38
+ readonly multipart?: {
39
+ begin(key: string): Promise<string>;
40
+ part(uploadId: string, partNumber: number, body: Readable, size: number, expectedSha256: string | null): Promise<string>;
41
+ complete(uploadId: string): Promise<string>;
42
+ abort(uploadId: string): Promise<void>;
43
+ };
44
+ }
45
+ export interface GatewayOptions {
46
+ /**
47
+ * Every pair that may sign a request here, each optionally held to named buckets.
48
+ *
49
+ * ⛔ A LIST RATHER THAN ONE PAIR BECAUSE A BUCKET IS AN ACCOUNT. `nmts s3` makes one pair for one
50
+ * drive; a business serving many of its users' accounts hands each of them a pair of their
51
+ * own, and the restriction on the pair is what stops one customer reading another's bucket.
52
+ */
53
+ readonly credentials: readonly GatewayCredential[];
54
+ /**
55
+ * Which drive answers to this bucket name, or null when none does.
56
+ *
57
+ * ⛔ THE GATEWAY DOES NOT KNOW WHAT A BUCKET IS. It was one name and one drive for as long as the
58
+ * only caller was the command-line tool; asked by a business's server it is a lookup that
59
+ * server does, and one it may do differently per name. What must not change is that a name
60
+ * this resolver refuses looks exactly like a name the caller may not touch (see below).
61
+ */
62
+ readonly bucketOf: (name: string) => DriveSource | null | Promise<DriveSource | null>;
63
+ /**
64
+ * The names `ListBuckets` answers with, before the signing pair's own restriction is applied.
65
+ *
66
+ * ⚠ ABSENT IS A REAL ANSWER RATHER THAN A GAP. A gateway in front of a business's own lookup
67
+ * cannot enumerate its customers, so what it can honestly name is what the presented pair is
68
+ * held to — and an unrestricted pair on such a gateway is told nothing, which is true.
69
+ */
70
+ readonly bucketNames?: () => readonly string[] | Promise<readonly string[]>;
71
+ /** Called with one line whenever a request is answered, so a person can watch what a tool does. */
72
+ readonly log?: (line: string) => void;
73
+ /** Passed in so a test can hold the clock still. */
74
+ readonly now?: () => number;
75
+ /**
76
+ * The sentence a write gets from a read-only drive. `nmts s3` says what a person runs on this
77
+ * machine to allow spending; a gateway somebody else runs has a different way in, and says its own.
78
+ */
79
+ readonly readOnlyBecause?: string | undefined;
80
+ }
@@ -0,0 +1,3 @@
1
+ // What whoever runs the gateway has to hand it: where a bucket's files come from, how (or whether)
2
+ // they may be changed, and who may sign for them.
3
+ export {};
@@ -0,0 +1,108 @@
1
+ import type { PlaintextSink } from "../download-sink.ts";
2
+ import type { Network } from "../network.ts";
3
+ import type { ManifestEntry } from "../shared/lib/drive/manifest-codec.ts";
4
+ import type { ReadOptions } from "../walrus.ts";
5
+ import type { DriveObject } from "./listing.ts";
6
+ import type { DriveSource } from "./server.ts";
7
+ import { type Staging } from "./staging.ts";
8
+ /**
9
+ * How long a file list may be reused before it is fetched again.
10
+ *
11
+ * ⛔ THERE IS A CACHE BECAUSE A SYNC IS THOUSANDS OF REQUESTS. Reading the list per request would
12
+ * mean a server round trip and a decryption for each one, so a listing of a large drive would
13
+ * take minutes and cost the account's rate budget. ⚠ It also means a file uploaded from another
14
+ * device can be up to this long in appearing here, which is the trade and is written in the
15
+ * tool's own words when it starts.
16
+ */
17
+ export declare const LIST_CACHE_MS = 5000;
18
+ /**
19
+ * Where a drive comes from, for whoever is running the gateway.
20
+ *
21
+ * ⛔ SIX FUNCTIONS AND NO CREDENTIAL. The command-line tool holds an open session; the SDK holds a
22
+ * client whose key may be in a business's sealed store and is borrowed one call at a time.
23
+ * Nothing in this file may care which, so nothing in this file is handed a key -- `withCode`
24
+ * borrows one for the length of a comparison and the caller decides what that costs.
25
+ */
26
+ export interface DriveAccount {
27
+ /** The account's file list, read fresh from the server. Empty for an account that has none. */
28
+ readList(): Promise<readonly ManifestEntry[]>;
29
+ /**
30
+ * Borrow the account's code for the length of `use`.
31
+ *
32
+ * ⚠ ASKED FOR ONE THING ONLY: opening the hash this drive recorded for a file already at the key,
33
+ * which is sealed under the account's own data key and cannot be compared without it.
34
+ */
35
+ withCode<T>(use: (code: string) => Promise<T>): Promise<T>;
36
+ /** Make this folder path, and any folder above it that is missing. */
37
+ makeFolder(path: string): Promise<void>;
38
+ /** Store one local file under `name`, in `folder` — the top of the account when undefined. */
39
+ store(local: string, name: string, folder: string | undefined): Promise<void>;
40
+ /** Send one path — with its leading slash — to the trash, where it stays for thirty days. */
41
+ trash(path: string): Promise<void>;
42
+ /** Fetch, decrypt and deliver one file into the sink. `fetchObject` below is how both do it. */
43
+ fetch(object: DriveObject, sink: PlaintextSink): Promise<void>;
44
+ }
45
+ export interface DriveSourceOptions {
46
+ readonly account: DriveAccount;
47
+ /**
48
+ * Where the pieces of a multipart upload wait until they are one file.
49
+ *
50
+ * ⛔ 0700, AND MADE WHEN IT IS FIRST NEEDED. Pieces are somebody's plaintext; leaving them in a
51
+ * shared temporary directory under a predictable name would put them where any other account
52
+ * on the machine could read them, for as long as the upload takes and afterwards.
53
+ */
54
+ readonly stagingRoot: string;
55
+ /**
56
+ * False makes this drive read only, and that is a refusal rather than a gap — the gateway
57
+ * answers every write with the sentence naming what would allow it.
58
+ */
59
+ readonly writable: boolean;
60
+ /**
61
+ * The staging an earlier source for the same bucket was using, when there was one.
62
+ *
63
+ * ⛔ AN UPLOAD IN PIECES OUTLIVES THE SOURCE IT BEGAN UNDER. A caller that rebuilds its sources —
64
+ * a gateway re-asking whose bucket this is — would otherwise hand the next piece to a staging
65
+ * that has never heard of the upload, and a large file could never finish.
66
+ */
67
+ readonly multipart?: Staging | undefined;
68
+ /** How long a file list may be reused. `LIST_CACHE_MS` unless a caller has a reason. */
69
+ readonly listCacheMs?: number | undefined;
70
+ /**
71
+ * Told the key when it already held exactly these bytes, so nothing was sent.
72
+ *
73
+ * ⭐ NOT AN ERROR. An unchanged file costs nothing to re-offer, which is what stops a backup that
74
+ * runs nightly paying for the nights nothing changed. The words a person reads are the
75
+ * caller's — this file has no terminal.
76
+ */
77
+ readonly onAlreadyStored?: ((key: string) => void) | undefined;
78
+ }
79
+ /** Everything `fetchObject` needs to open one file: where to ask, and whose key opens it. */
80
+ export interface ObjectReader {
81
+ readonly server: string;
82
+ /**
83
+ * What goes in the one header the server reads: an API key, or a delegation token.
84
+ *
85
+ * ⛔ NAMED FOR WHAT IT IS RATHER THAN FOR ONE OF THE TWO. A field called `apiKey` carrying a
86
+ * delegation token is how a reader comes to believe a delegated client cannot do something it
87
+ * can.
88
+ */
89
+ readonly bearer: string;
90
+ readonly code: string;
91
+ readonly chain: Network;
92
+ /** Hosts to read stored bytes from, instead of the network's own aggregators. */
93
+ readonly read?: ReadOptions | undefined;
94
+ }
95
+ /**
96
+ * `photos/2026/a.jpg` → the folder to make and the name to store under.
97
+ *
98
+ * A key with no slash lands at the top of the account, which is `undefined` rather than `""`: the
99
+ * two mean the same thing to a person and different things to the upload path.
100
+ */
101
+ export declare function placeOf(key: string): {
102
+ folder: string | undefined;
103
+ name: string;
104
+ };
105
+ /** The real reader: the stored bytes, opened with this account's key and delivered to the sink. */
106
+ export declare function fetchObject(reader: ObjectReader, object: DriveObject, sink: PlaintextSink): Promise<void>;
107
+ /** One account as the protocol layer sees it: a cached list, a reader, and a writer when allowed. */
108
+ export declare function createDriveSource(options: DriveSourceOptions): DriveSource;
@@ -0,0 +1,136 @@
1
+ // One account behind the gateway: its file list, its reader, and the rule every upload passes.
2
+ //
3
+ // ⛔ ONE IMPLEMENTATION, TWO CALLERS, WHICH IS THE WHOLE REASON THIS FILE EXISTS. `nmts s3` serves
4
+ // the drive of whoever is at this machine; the SDK's gateway serves whichever account a
5
+ // business's resolver hands back. What the two do with an upload -- spool it, ask whether the
6
+ // key already holds exactly these bytes, make the folders above it, store it, forget the cached
7
+ // list -- is the same work, and a second copy of it would be a second place for the same-file
8
+ // rule to be got right. The two differ only in where the account comes from, which is the seam
9
+ // below.
10
+ //
11
+ // ⛔ THE SAME-FILE QUESTION IS ANSWERED HERE AND NOWHERE ELSE. Both ways of uploading -- one PUT,
12
+ // or pieces staged and joined -- end in `storeFile`, so a rule written here cannot disagree with
13
+ // itself; written in the protocol layer it would have to be written twice, once for each, and
14
+ // the two would differ the first time one of them changed.
15
+ import { randomUUID } from "node:crypto";
16
+ import { createWriteStream } from "node:fs";
17
+ import { mkdir as makeDir, rm as removeFile, stat } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { pipeline } from "node:stream/promises";
20
+ import { fetchFile } from "../download.js";
21
+ import { NmtsError } from "../errors.js";
22
+ import { refusalFor, verdictForKey } from "./same-file.js";
23
+ import { createStaging } from "./staging.js";
24
+ /**
25
+ * How long a file list may be reused before it is fetched again.
26
+ *
27
+ * ⛔ THERE IS A CACHE BECAUSE A SYNC IS THOUSANDS OF REQUESTS. Reading the list per request would
28
+ * mean a server round trip and a decryption for each one, so a listing of a large drive would
29
+ * take minutes and cost the account's rate budget. ⚠ It also means a file uploaded from another
30
+ * device can be up to this long in appearing here, which is the trade and is written in the
31
+ * tool's own words when it starts.
32
+ */
33
+ export const LIST_CACHE_MS = 5_000;
34
+ /**
35
+ * `photos/2026/a.jpg` → the folder to make and the name to store under.
36
+ *
37
+ * A key with no slash lands at the top of the account, which is `undefined` rather than `""`: the
38
+ * two mean the same thing to a person and different things to the upload path.
39
+ */
40
+ export function placeOf(key) {
41
+ const at = key.lastIndexOf("/");
42
+ if (at < 0)
43
+ return { folder: undefined, name: key };
44
+ const folder = key.slice(0, at);
45
+ return { folder: folder === "" ? undefined : folder, name: key.slice(at + 1) };
46
+ }
47
+ /** The real reader: the stored bytes, opened with this account's key and delivered to the sink. */
48
+ export async function fetchObject(reader, object, sink) {
49
+ const wrapped = object.entry.dekWrapped;
50
+ if (wrapped === undefined)
51
+ throw new NmtsError("That entry has no key in the file list.");
52
+ await fetchFile({
53
+ base: reader.server,
54
+ apiKey: reader.bearer,
55
+ accountCode: reader.code,
56
+ itemId: object.entry.id,
57
+ size: object.size,
58
+ dekWrapped: wrapped,
59
+ contentHashCt: object.entry.contentHashCt,
60
+ chain: reader.chain,
61
+ sink,
62
+ ...(reader.read === undefined ? {} : { read: reader.read }),
63
+ });
64
+ }
65
+ /** One account as the protocol layer sees it: a cached list, a reader, and a writer when allowed. */
66
+ export function createDriveSource(options) {
67
+ const account = options.account;
68
+ const cacheMs = options.listCacheMs ?? LIST_CACHE_MS;
69
+ let cached = [];
70
+ let cachedAt = 0;
71
+ const entries = async () => {
72
+ if (Date.now() - cachedAt < cacheMs)
73
+ return cached;
74
+ cached = await account.readList();
75
+ cachedAt = Date.now();
76
+ return cached;
77
+ };
78
+ /**
79
+ * Store one local file at a drive key, making the folders above it if they are missing.
80
+ *
81
+ * ⭐ IDENTICAL CONTENT IS NOT AN ERROR. Nothing is sent and nothing is charged, and the caller is
82
+ * told the upload finished — because the statement it was making, "that file is at that key",
83
+ * is true. Answering 409 there is what made every backup run fail on every file it had already
84
+ * stored, and a sync tool writes 409 down as a failure.
85
+ */
86
+ const storeFile = async (key, path) => {
87
+ const { folder, name } = placeOf(key);
88
+ const known = await entries();
89
+ const verdict = await account.withCode((code) => verdictForKey(known, key, code, path));
90
+ if (verdict === "same") {
91
+ options.onAlreadyStored?.(key);
92
+ return;
93
+ }
94
+ if (verdict !== "free")
95
+ throw refusalFor(verdict, key);
96
+ if (folder !== undefined)
97
+ await account.makeFolder(folder);
98
+ await account.store(path, name, folder);
99
+ cachedAt = 0;
100
+ };
101
+ return {
102
+ entries,
103
+ fetch: (object, sink) => account.fetch(object, sink),
104
+ ...(options.writable
105
+ ? {
106
+ write: {
107
+ // ⛔ THE BODY IS SPOOLED TO A FILE FIRST, 0600, and deleted whatever happens. The
108
+ // upload path reserves storage, cuts parts and seals them from a file, and giving it
109
+ // a socket instead would mean either holding whole uploads in memory or writing a
110
+ // second upload path — and a second upload path is a second place for "what if the
111
+ // reservation succeeds and the part fails" to be got right.
112
+ put: async (key, body, size) => {
113
+ await makeDir(options.stagingRoot, { recursive: true, mode: 0o700 });
114
+ const spool = join(options.stagingRoot, randomUUID());
115
+ try {
116
+ await pipeline(body, createWriteStream(spool, { mode: 0o600 }));
117
+ const written = (await stat(spool)).size;
118
+ if (written !== size) {
119
+ throw new NmtsError(`The upload said ${size} bytes and ${written} arrived. Nothing was stored.`);
120
+ }
121
+ await storeFile(key, spool);
122
+ }
123
+ finally {
124
+ await removeFile(spool, { force: true });
125
+ }
126
+ },
127
+ multipart: options.multipart ?? createStaging(options.stagingRoot, storeFile),
128
+ trash: async (object) => {
129
+ await account.trash(`/${object.key}`);
130
+ cachedAt = 0;
131
+ },
132
+ },
133
+ }
134
+ : {}),
135
+ };
136
+ }
@@ -1,6 +1,13 @@
1
1
  import type { ManifestEntry } from "../shared/lib/drive/manifest-codec.ts";
2
2
  import type { ObjectRow } from "./xml.ts";
3
- /** The one bucket. Named for what it is, and not configurable: two names for one drive is worse. */
3
+ /**
4
+ * The bucket `nmts s3` serves. Named for what it is, and not configurable: two names for one drive
5
+ * is worse.
6
+ *
7
+ * ⚠ IT IS THIS COMMAND'S ANSWER, NOT THE SERVER'S RULE. The gateway takes a resolver, because a
8
+ * business running it in front of many of its users' accounts has one bucket per account; what
9
+ * `nmts s3` hands it is a resolver that knows this name and no other.
10
+ */
4
11
  export declare const BUCKET = "drive";
5
12
  /** S3's own ceiling, and the default when a client does not ask for one. */
6
13
  export declare const MAX_KEYS_LIMIT = 1000;
@@ -13,7 +13,14 @@
13
13
  // prefix. Real S3 has no folders, so an empty one cannot exist there; this drive does have them,
14
14
  // and hiding them would make `rclone lsd` describe a drive that is not the one in the browser.
15
15
  import { buildIndex, fullPathOf, isLive, KIND_FOLDER } from "../drive-paths.js";
16
- /** The one bucket. Named for what it is, and not configurable: two names for one drive is worse. */
16
+ /**
17
+ * The bucket `nmts s3` serves. Named for what it is, and not configurable: two names for one drive
18
+ * is worse.
19
+ *
20
+ * ⚠ IT IS THIS COMMAND'S ANSWER, NOT THE SERVER'S RULE. The gateway takes a resolver, because a
21
+ * business running it in front of many of its users' accounts has one bucket per account; what
22
+ * `nmts s3` hands it is a resolver that knows this name and no other.
23
+ */
17
24
  export const BUCKET = "drive";
18
25
  /** S3's own ceiling, and the default when a client does not ask for one. */
19
26
  export const MAX_KEYS_LIMIT = 1000;
@@ -0,0 +1,4 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { GatewayOptions } from "./contract.ts";
3
+ export declare function fail(res: ServerResponse, status: number, code: string, message: string, resource: string): void;
4
+ export declare function handle(req: IncomingMessage, res: ServerResponse, options: GatewayOptions): Promise<void>;
@@ -0,0 +1,249 @@
1
+ // Which answer an S3 request gets: the signature first, then the bucket, then the verb.
2
+ //
3
+ // ⛔ WHAT IS NOT ANSWERED IS REFUSED, LOUDLY. An S3 client that asks for something this gateway does
4
+ // not do gets 501 and a sentence naming what it does do. The alternative -- answering an empty
5
+ // listing, or a 200 with nothing behind it -- is how a backup tool reports success over a backup
6
+ // that never happened.
7
+ import { listObjects, objectsOf, folderPrefixesOf, MAX_KEYS_LIMIT } from "./listing.js";
8
+ import { handleMultipart, isMultipartRequest } from "./multipart.js";
9
+ import { responseSink } from "./response-sink.js";
10
+ import { isKeyConflict } from "./same-file.js";
11
+ import { STREAMING_PAYLOAD, STREAMING_PAYLOAD_TRAILER, verifyAgainst, } from "./sigv4.js";
12
+ import { errorXml, listBucketsXml, listObjectsXml } from "./xml.js";
13
+ /**
14
+ * What a caller signed with a pair it may not use here.
15
+ *
16
+ * ⛔ THE SAME ANSWER WHETHER OR NOT THE BUCKET EXISTS, which is why the restriction is checked
17
+ * before the resolver is asked. Answering `NoSuchBucket` for a name the caller may not touch
18
+ * would turn this gateway into a way of asking "does this business have a customer called…",
19
+ * one guess at a time.
20
+ */
21
+ const NOT_YOURS = "That access key may not use that bucket.";
22
+ export function fail(res, status, code, message, resource) {
23
+ const body = errorXml(code, message, resource);
24
+ res.writeHead(status, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
25
+ res.end(body);
26
+ }
27
+ /** `/drive/photos/a.jpg` → bucket `drive`, key `photos/a.jpg`. */
28
+ function splitPath(pathname) {
29
+ const trimmed = pathname.replace(/^\//, "");
30
+ const at = trimmed.indexOf("/");
31
+ if (at < 0)
32
+ return { bucket: decodeURIComponent(trimmed), key: "" };
33
+ return { bucket: decodeURIComponent(trimmed.slice(0, at)), key: decodeURIComponent(trimmed.slice(at + 1)) };
34
+ }
35
+ function headerOf(req, name) {
36
+ const raw = req.headers[name];
37
+ return Array.isArray(raw) ? raw.join(",") : raw;
38
+ }
39
+ /** The one sentence a write gets from `nmts s3` when this machine has not agreed to spending. */
40
+ function readOnlyOnThisMachine() {
41
+ return ("This gateway is read only. Uploading spends credits, and this machine has not agreed to " +
42
+ "spending — `nmts consent grant spend`, run by the person whose account this is, is what " +
43
+ "changes that. Nothing was written.");
44
+ }
45
+ /** Whether the pair that signed is allowed anywhere near this bucket name. */
46
+ function mayTouch(credential, bucket) {
47
+ const only = credential.buckets;
48
+ return only === undefined || only.includes(bucket);
49
+ }
50
+ /** What `ListBuckets` says: what the gateway can name, narrowed to what this pair may touch. */
51
+ async function bucketsFor(options, credential) {
52
+ const only = credential.buckets;
53
+ if (options.bucketNames === undefined)
54
+ return only ?? [];
55
+ const named = await options.bucketNames();
56
+ return only === undefined ? named : named.filter((name) => only.includes(name));
57
+ }
58
+ function objectHeaders(object) {
59
+ return {
60
+ "content-type": "application/octet-stream",
61
+ "last-modified": new Date(object.entry.updatedAt).toUTCString(),
62
+ etag: object.etag,
63
+ "accept-ranges": "none",
64
+ };
65
+ }
66
+ export async function handle(req, res, options) {
67
+ const url = req.url ?? "/";
68
+ const at = url.indexOf("?");
69
+ const pathname = at < 0 ? url : url.slice(0, at);
70
+ const query = new URLSearchParams(at < 0 ? "" : url.slice(at + 1));
71
+ const method = (req.method ?? "GET").toUpperCase();
72
+ const verdict = verifyAgainst({ method, url, headers: req.headers }, options.credentials, options.now?.() ?? Date.now());
73
+ if (!verdict.ok) {
74
+ fail(res, 403, verdict.code, verdict.message, pathname);
75
+ return;
76
+ }
77
+ const credential = verdict.credential;
78
+ const { bucket, key } = splitPath(pathname);
79
+ if (pathname === "/" && (method === "GET" || method === "HEAD")) {
80
+ const body = listBucketsXml(await bucketsFor(options, credential), new Date(0).toISOString());
81
+ res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
82
+ res.end(method === "HEAD" ? undefined : body);
83
+ return;
84
+ }
85
+ if (!mayTouch(credential, bucket)) {
86
+ fail(res, 403, "AccessDenied", NOT_YOURS, pathname);
87
+ return;
88
+ }
89
+ const source = await options.bucketOf(bucket);
90
+ if (source === null) {
91
+ fail(res, 404, "NoSuchBucket", `No bucket named ${bucket} is served here.`, pathname);
92
+ return;
93
+ }
94
+ // ⛔ MEASURED, NOT GUESSED: rclone's first act when copying a file is to create the bucket, and a
95
+ // refusal here ends the copy before the upload is ever attempted. The bucket exists, so the
96
+ // honest answer to "make it" is that it is made.
97
+ if (key === "" && method === "PUT") {
98
+ res.writeHead(200, { "content-length": "0" });
99
+ res.end();
100
+ return;
101
+ }
102
+ const entries = await source.entries();
103
+ if (key === "" && (method === "GET" || method === "HEAD")) {
104
+ const objects = objectsOf(entries);
105
+ const listing = listObjects(objects, folderPrefixesOf(entries), {
106
+ prefix: query.get("prefix") ?? "",
107
+ delimiter: query.get("delimiter") ?? "",
108
+ maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
109
+ after: query.get("continuation-token") ?? query.get("start-after") ?? query.get("marker"),
110
+ });
111
+ const body = listObjectsXml({
112
+ bucket,
113
+ prefix: query.get("prefix") ?? "",
114
+ delimiter: query.get("delimiter") ?? "",
115
+ maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
116
+ v2: query.get("list-type") === "2",
117
+ contents: listing.contents,
118
+ commonPrefixes: listing.commonPrefixes,
119
+ truncated: listing.truncated,
120
+ next: listing.next,
121
+ encodingType: query.get("encoding-type"),
122
+ });
123
+ res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
124
+ res.end(method === "HEAD" ? undefined : body);
125
+ options.log?.(`${method} list prefix=${query.get("prefix") ?? ""} → ${listing.contents.length} keys`);
126
+ return;
127
+ }
128
+ if (method === "HEAD" || method === "GET") {
129
+ const object = objectsOf(entries).find((o) => o.key === key);
130
+ if (object === undefined) {
131
+ fail(res, 404, "NoSuchKey", "This account's file list has no such file.", pathname);
132
+ return;
133
+ }
134
+ if (method === "HEAD") {
135
+ res.writeHead(200, { ...objectHeaders(object), "content-length": String(object.size) });
136
+ res.end();
137
+ options.log?.(`HEAD ${key}`);
138
+ return;
139
+ }
140
+ if (object.entry.dekWrapped === undefined) {
141
+ fail(res, 500, "InternalError", "That entry has no key in the file list.", pathname);
142
+ return;
143
+ }
144
+ const sink = responseSink(res, { headers: objectHeaders(object) });
145
+ try {
146
+ await source.fetch(object, sink);
147
+ options.log?.(`GET ${key} → ${object.size} bytes`);
148
+ }
149
+ catch (error) {
150
+ await sink.abandon();
151
+ if (!res.headersSent) {
152
+ fail(res, 502, "InternalError", error instanceof Error ? error.message : String(error), pathname);
153
+ }
154
+ options.log?.(`GET ${key} → failed`);
155
+ }
156
+ return;
157
+ }
158
+ const writer = source.write;
159
+ // ⛔ WHETHER A TAKEN KEY IS A CONFLICT IS NOT DECIDED HERE.
160
+ // It used to be, on the strength of the NAME alone, and both upload paths carried their own
161
+ // copy of that check. The question is now about CONTENT — is the file arriving the file
162
+ // already there — and it cannot be answered until the bytes have arrived, so it is answered
163
+ // once, by the writer, at the point both paths meet. What reaches this layer is the verdict:
164
+ // a writer that returns normally means the key now holds these bytes (whether it had to send
165
+ // them or they were already there), and one that throws a conflict means something else is at
166
+ // that key. ⭐ The status matters: 409 is a request the drive declined, 500 is a fault of ours.
167
+ const refuseConflict = (error) => {
168
+ fail(res, 409, "InvalidRequest", error instanceof Error ? error.message : String(error), pathname);
169
+ };
170
+ if (isMultipartRequest(method, query) && key !== "") {
171
+ if (writer === undefined) {
172
+ fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
173
+ return;
174
+ }
175
+ const handled = await handleMultipart({
176
+ req,
177
+ res,
178
+ bucket,
179
+ key,
180
+ method,
181
+ query,
182
+ writer,
183
+ payloadHash: /^[0-9a-f]{64}$/.test(verdict.payloadHash) ? verdict.payloadHash : null,
184
+ fail: (status, code, message) => fail(res, status, code, message, pathname),
185
+ ...(options.log === undefined ? {} : { log: options.log }),
186
+ });
187
+ if (handled)
188
+ return;
189
+ }
190
+ if (method === "PUT" && key !== "") {
191
+ if (writer === undefined) {
192
+ fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
193
+ return;
194
+ }
195
+ const declared = headerOf(req, "x-amz-content-sha256");
196
+ if (declared === STREAMING_PAYLOAD || declared === STREAMING_PAYLOAD_TRAILER) {
197
+ fail(res, 501, "NotImplemented", "This gateway does not read chunk-signed uploads yet. Tell the client to send the body " +
198
+ "unsigned (the AWS CLI calls this --no-sign-payload on http endpoints; rclone already " +
199
+ "does it).", pathname);
200
+ return;
201
+ }
202
+ const length = Number(headerOf(req, "content-length") ?? "");
203
+ if (!Number.isInteger(length) || length < 0) {
204
+ fail(res, 411, "MissingContentLength", "This gateway needs to know the size before it starts.", pathname);
205
+ return;
206
+ }
207
+ try {
208
+ await writer.put(key, req, length);
209
+ }
210
+ catch (error) {
211
+ if (isKeyConflict(error)) {
212
+ refuseConflict(error);
213
+ return;
214
+ }
215
+ fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
216
+ return;
217
+ }
218
+ res.writeHead(200, { "content-length": "0" });
219
+ res.end();
220
+ options.log?.(`PUT ${key} → ${length} bytes`);
221
+ return;
222
+ }
223
+ if (method === "DELETE" && key !== "") {
224
+ if (writer === undefined) {
225
+ fail(res, 501, "NotImplemented", options.readOnlyBecause ?? readOnlyOnThisMachine(), pathname);
226
+ return;
227
+ }
228
+ const object = objectsOf(entries).find((o) => o.key === key);
229
+ if (object === undefined) {
230
+ // S3 answers 204 for a key that is not there, and clients rely on it: a sync that deletes
231
+ // the same key twice must not fail the second time.
232
+ res.writeHead(204);
233
+ res.end();
234
+ return;
235
+ }
236
+ try {
237
+ await writer.trash(object);
238
+ }
239
+ catch (error) {
240
+ fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
241
+ return;
242
+ }
243
+ res.writeHead(204);
244
+ res.end();
245
+ options.log?.(`DELETE ${key} → trash`);
246
+ return;
247
+ }
248
+ fail(res, 501, "NotImplemented", `This gateway does not answer ${method} on that address.`, pathname);
249
+ }