@needmoretruth/nmts-cli 0.17.2

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 (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
@@ -0,0 +1,54 @@
1
+ // What the NMTS crypto engine must provide, declared once so a missing piece is named at load
2
+ // time rather than deep inside a derivation.
3
+ //
4
+ // ⛔ SPLIT OUT OF `crypto.ts` RATHER THAN REWRITTEN. Every line below was in that file and is
5
+ // unchanged; it moved because that file crossed the length gate, and this is where the seam
6
+ // already was: this half is the ENGINE'S SURFACE — the shape the WebAssembly must have — and
7
+ // what is left there is finding it on disk and loading it. Nothing here reaches the filesystem
8
+ // and nothing here derives anything.
9
+ //
10
+ // ⛔ NO TYPE ASSERTION, and that is the reason this is a runtime check and not only a type. A
11
+ // dynamically imported module is `unknown`; an `as` would turn a renamed export into
12
+ // "undefined is not a function" halfway through deriving somebody's keys.
13
+ const REQUIRED = [
14
+ "account_code_generate",
15
+ "account_code_parse",
16
+ "account_code_display",
17
+ "kdf_derive",
18
+ "share_address_display",
19
+ "share_address_parse",
20
+ "share_public_key",
21
+ "share_address_of",
22
+ "share_claimed_sender",
23
+ "share_wrap_dek",
24
+ "share_unwrap_dek",
25
+ "envelope_open",
26
+ "stream_decrypt_all",
27
+ "generate_dek",
28
+ "envelope_seal",
29
+ "stream_encrypt_all",
30
+ "StreamEncryptor",
31
+ "StreamDecryptor",
32
+ "wallet_seed_for",
33
+ ];
34
+ export function isCryptoGlue(value) {
35
+ return missingExports(value).length === 0;
36
+ }
37
+ /** Which of the required functions this object does not have. Empty means it is the engine. */
38
+ export function missingExports(value) {
39
+ if (typeof value !== "object" || value === null)
40
+ return [...REQUIRED];
41
+ return REQUIRED.filter((name) => {
42
+ if (!(name in value))
43
+ return true;
44
+ const member = Reflect.get(value, name);
45
+ return typeof member !== "function";
46
+ });
47
+ }
48
+ /**
49
+ * Where the engine is.
50
+ *
51
+ * Two layouts are real: inside the published package the vendored engine sits beside the code, and
52
+ * inside this repository it lives in web/vendor where the browser build also reads it. Looking in
53
+ * both is what lets the same source run from a checkout and from an install.
54
+ */
package/dist/crypto.js ADDED
@@ -0,0 +1,137 @@
1
+ // Loading the real NMTS crypto engine in Node.
2
+ //
3
+ // ⛔ NOTHING IS RE-IMPLEMENTED HERE. The bytes that derive an account's keys are the same
4
+ // WebAssembly the browser runs, built from the same Rust crate. Two harnesses in this repo
5
+ // already load it exactly this way and a gate refuses green if their assertion count drops, so
6
+ // this path is held by a machine and not by memory. A TypeScript re-derivation of NCF-3 would
7
+ // be a second implementation that can drift from the first, silently and in the direction that
8
+ // loses files.
9
+ //
10
+ // ⛔ NO TYPE ASSERTION. A dynamically imported module is `unknown` and staying honest about that
11
+ // matters here more than anywhere: if a rebuild renamed an export, an `as` would turn that into
12
+ // "undefined is not a function" deep inside a derivation. The guard below checks each function
13
+ // exists before anything is derived, so a missing export is named at load time.
14
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
15
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
16
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
17
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
18
+ });
19
+ }
20
+ return path;
21
+ };
22
+ import { readFile } from "node:fs/promises";
23
+ import { existsSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath, pathToFileURL } from "node:url";
26
+ import { NmtsError } from "./errors.js";
27
+ import { isCryptoGlue, missingExports } from "./crypto-surface.js";
28
+ /**
29
+ * The associated-data strings of NCF-3, for the envelopes this tool opens.
30
+ *
31
+ * ⛔ FROZEN (NCF-3 §2.2). They are the separator between one purpose and another: the file list
32
+ * cannot be opened as a delegation, and neither can be opened as a file. Copied here rather
33
+ * than imported because this package does not import the browser tree — the conformance
34
+ * vectors are what arbitrate, and they are in the crypto repository.
35
+ */
36
+ export const AAD = {
37
+ fileList: "nmts/v3/file-list",
38
+ /** Wraps a file's own key under the account's data key (NCF-3 §3). */
39
+ dekWrap: "nmts/v3/dek-wrap",
40
+ /**
41
+ * Wraps the SHA-256 of a file's whole plaintext (NCF-3 §2.2).
42
+ *
43
+ * Sealed rather than stored bare because a plaintext content hash identifies the FILE: it is
44
+ * matchable against public hash sets, and it is equal across two accounts holding the same file.
45
+ */
46
+ contentHash: "nmts/v3/content-hash",
47
+ /**
48
+ * Seals a shared file's name and size FOR THE RECIPIENT, under the file's own key (NCF-3 §5.4).
49
+ *
50
+ * ⚠ A different separator from the account's own file list on purpose: the recipient holds the
51
+ * file key and nothing else, so the name has to travel under that key rather than under an
52
+ * account data key they do not have.
53
+ */
54
+ shareName: "nmts/v3/share-name",
55
+ /** Seals the shared file's plaintext digest for the recipient, under the same file key. */
56
+ shareContentHash: "nmts/v3/share-content-hash",
57
+ /** Seals the RECOVERY LIST — where every file's bytes are (NRM §1). ⛔ The old spelling of the
58
+ * artefact's name is frozen INTO the separator: every list ever sealed is bound to these bytes. */
59
+ recoveryMap: "nmts/v3/recovery-map",
60
+ };
61
+ /**
62
+ * Byte ranges inside `kdf_derive`'s output.
63
+ *
64
+ * ⛔ These are a CONTRACT WITH A FROZEN FORMAT (NCF-3 §1), not a convenience. They are written
65
+ * here as ranges rather than magic numbers at call sites so a reader can check them against the
66
+ * format document in one place — and so a future version bump changes one table.
67
+ */
68
+ export const DERIVED = {
69
+ accountId: [0, 16],
70
+ authSecret: [16, 48],
71
+ dataKey: [48, 80],
72
+ fileListKey: [80, 112],
73
+ /**
74
+ * The three secrets behind this account's sharing identity (NCF-3 §5.1).
75
+ *
76
+ * ⚠ `shareSigSeed` SITS AT THE TAIL, not beside the other two, and that is not tidiness lost —
77
+ * it was appended on 2026-08-02 because filing it in the obvious place would have shifted
78
+ * `walletRoot` and `shareAddress`, which are frozen. `web/src/lib/crypto/kdf-offsets.ts`
79
+ * carries the same table for the browser.
80
+ */
81
+ shareKemSeed: [112, 144],
82
+ shareAuthSecret: [144, 176],
83
+ /**
84
+ * The root every one of this account's wallets is derived from.
85
+ *
86
+ * ⚠ IT IS AN HKDF PRK, NOT KEY MATERIAL TO EXTRACT AGAIN. `wallet_seed_for` expands from it
87
+ * directly; a re-implementation that ran a second Extract over these bytes would produce
88
+ * plausible-looking seeds for a different set of addresses.
89
+ */
90
+ walletRoot: [176, 208],
91
+ shareAddress: [208, 224],
92
+ shareSigSeed: [224, 256],
93
+ };
94
+ export function engineDir() {
95
+ const here = dirname(fileURLToPath(import.meta.url));
96
+ const candidates = [
97
+ join(here, "..", "vendor", "nmts-crypto"),
98
+ join(here, "..", "..", "vendor", "nmts-crypto"),
99
+ join(here, "..", "..", "web", "vendor", "nmts-crypto"),
100
+ ];
101
+ for (const dir of candidates) {
102
+ if (existsSync(join(dir, "nmts_crypto_wasm_bg.wasm")))
103
+ return dir;
104
+ }
105
+ throw new NmtsError("The NMTS crypto engine is missing from this installation.", {
106
+ exitCode: 1,
107
+ nextStep: "Reinstall the package. Nothing can be encrypted or decrypted without it.",
108
+ });
109
+ }
110
+ let cached = null;
111
+ /** Load the engine once per process. */
112
+ export async function loadCrypto() {
113
+ if (cached !== null)
114
+ return cached;
115
+ const dir = engineDir();
116
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(join(dir, "nmts_crypto_wasm.js")).href));
117
+ if (typeof module !== "object" || module === null || !("default" in module)) {
118
+ throw new NmtsError("The NMTS crypto engine did not load (no initialiser).", { exitCode: 1 });
119
+ }
120
+ const init = Reflect.get(module, "default");
121
+ if (typeof init !== "function") {
122
+ throw new NmtsError("The NMTS crypto engine did not load (initialiser is not callable).", {
123
+ exitCode: 1,
124
+ });
125
+ }
126
+ await init({ module_or_path: await readFile(join(dir, "nmts_crypto_wasm_bg.wasm")) });
127
+ if (!isCryptoGlue(module)) {
128
+ const missing = missingExports(module);
129
+ throw new NmtsError(`The NMTS crypto engine is missing: ${missing.join(", ")}. This build does not match this tool.`, { exitCode: 1, nextStep: "Reinstall the package." });
130
+ }
131
+ cached = module;
132
+ return module;
133
+ }
134
+ /** For tests that need a fresh load. */
135
+ export function forgetCrypto() {
136
+ cached = null;
137
+ }
@@ -0,0 +1,144 @@
1
+ import { NmtsError } from "./errors.js";
2
+ import { NCF3_SHAPE } from "./seal.js";
3
+ import { isRecord } from "./guards.js";
4
+ import { readBlob, readQuiltPatch } from "./walrus.js";
5
+ import { NETWORK_WHEN_UNRECORDED, networkName } from "./shared/lib/storage-network.js";
6
+ /** `storage_kind` in the server's part rows. */
7
+ const DEDICATED_BLOB = 0;
8
+ const QUILT_PATCH = 1;
9
+ export function asParts(value) {
10
+ if (!isRecord(value))
11
+ throw new NmtsError("The server's answer was not an object.");
12
+ const v = value;
13
+ const size = v["size"];
14
+ const parts = v["parts"];
15
+ if (typeof size !== "number" || !Array.isArray(parts)) {
16
+ throw new NmtsError("The server described this file in a shape this version cannot read.", {
17
+ nextStep: "Update this tool, or open the file in a browser.",
18
+ });
19
+ }
20
+ const out = [];
21
+ for (const raw of parts) {
22
+ if (!isRecord(raw))
23
+ throw new NmtsError("A part was not an object.");
24
+ const p = raw;
25
+ if (typeof p["part_index"] !== "number" || typeof p["storage_kind"] !== "number" || typeof p["blob_id"] !== "string") {
26
+ throw new NmtsError("A part is missing the fields needed to read it.");
27
+ }
28
+ const view = {
29
+ part_index: p["part_index"],
30
+ storage_kind: p["storage_kind"],
31
+ blob_id: p["blob_id"],
32
+ };
33
+ if (typeof p["network"] === "number")
34
+ view.network = p["network"];
35
+ if (typeof p["patch_id"] === "string")
36
+ view.patch_id = p["patch_id"];
37
+ out.push(view);
38
+ }
39
+ return { size, parts: out };
40
+ }
41
+ /**
42
+ * Fetch one part's sealed bytes.
43
+ *
44
+ * ⛔ Refuse before reading, not after. A part on a storage network this build has no reader for
45
+ * would otherwise be fetched from a Walrus aggregator, 404, and be reported as missing bytes —
46
+ * which is a different thing and sends somebody looking for the wrong one.
47
+ */
48
+ export async function fetchPart(part, chain, read) {
49
+ const where = networkName(part.network ?? NETWORK_WHEN_UNRECORDED);
50
+ if (where !== "walrus") {
51
+ throw new NmtsError(`Part ${part.part_index} is stored on ${where ?? `an unknown network (${part.network})`}, which this version cannot read.`, { nextStep: "Nothing was written. Open the file in a browser, which may know that network." });
52
+ }
53
+ if (part.storage_kind !== QUILT_PATCH && part.storage_kind !== DEDICATED_BLOB) {
54
+ throw new NmtsError(`Part ${part.part_index} is stored in a way this version does not know (${part.storage_kind}).`);
55
+ }
56
+ return part.storage_kind === QUILT_PATCH && part.patch_id !== undefined
57
+ ? readQuiltPatch(chain, part.patch_id, read ?? {})
58
+ : readBlob(chain, part.blob_id, read ?? {});
59
+ }
60
+ /**
61
+ * Open ONE part and pass its contribution on, a chunk at a time. Returns how much of the file it
62
+ * contributed.
63
+ *
64
+ * ⛔ THE SEALED BYTES ARE FED IN ONE CHUNK AT A TIME, not all at once. Handing the engine the whole
65
+ * part would make it hand back the whole part's plaintext in one array, which is the ceiling
66
+ * this path exists to remove; feeding it a chunk's worth means at most one chunk of plaintext
67
+ * exists at a time. The size fed is the format's own chunk plus its tag, so a well-formed
68
+ * stream yields exactly one chunk per push — and a stream whose header declares a different
69
+ * chunk size still works, because the engine buffers what it has not finished.
70
+ *
71
+ * ⛔ `finish()` IS WHAT CATCHES A PART CUT SHORT. Every chunk that arrived authenticates; only the
72
+ * end-of-stream check knows the rest is missing. Skipping it would accept a truncated part.
73
+ *
74
+ * ⛔ THE ENGINE-SIDE SESSION IS FREED ON EVERY PATH OUT, including a failure: it holds the file
75
+ * key until it is, and a download that failed is exactly when nobody comes back to tidy up.
76
+ */
77
+ export async function openPart(crypt, dek, part, sealed, isLast, remaining, emit) {
78
+ const refuse = () => new NmtsError(`Part ${part.part_index} did not decrypt.`, {
79
+ nextStep: "The bytes that arrived are not the bytes this account sealed. Nothing was written. " +
80
+ "Try again — a different aggregator may hold the right ones.",
81
+ });
82
+ if (sealed.length < NCF3_SHAPE.headerLen)
83
+ throw refuse();
84
+ // ⚠ Constructed on the header alone, which is parsed and checked inside the engine, so a blob
85
+ // that is not an NCF-3 stream at all fails here rather than as a strange length later.
86
+ let opener;
87
+ try {
88
+ opener = new crypt.StreamDecryptor(dek, sealed.subarray(0, NCF3_SHAPE.headerLen));
89
+ }
90
+ catch {
91
+ throw refuse();
92
+ }
93
+ let taken = 0;
94
+ let left = remaining;
95
+ try {
96
+ // ⚠ A chunk's worth at a time: the format's chunk plus its tag. A well-formed stream yields
97
+ // exactly one chunk per push, and a header declaring some other chunk size still works —
98
+ // the engine buffers whatever it has not finished.
99
+ const feed = NCF3_SHAPE.chunkSize + NCF3_SHAPE.tagLen;
100
+ for (let at = NCF3_SHAPE.headerLen; at < sealed.length; at += feed) {
101
+ // ⛔ The push and the emit are in separate try blocks on purpose. Wrapping both would let a
102
+ // disk that filled up, or a pipe that refused, be reported as "this part did not
103
+ // decrypt" — sending somebody to look at the storage network for a fault on their own
104
+ // machine.
105
+ let run;
106
+ try {
107
+ run = opener.push(sealed.subarray(at, Math.min(at + feed, sealed.length)));
108
+ }
109
+ catch {
110
+ throw refuse();
111
+ }
112
+ if (run.length === 0)
113
+ continue;
114
+ // ⛔ Only the LAST part may hand back more than the file has left; that surplus is the
115
+ // padding the write side added to hide the true size. From any other part it means the
116
+ // file list and the stored bytes describe different files.
117
+ const take = isLast ? Math.min(run.length, left) : run.length;
118
+ if (take > left) {
119
+ run.fill(0);
120
+ throw new NmtsError(`Part ${part.part_index} contributes ${run.length} bytes and only ${left} of the file are left.`, { nextStep: "The file list and the stored parts do not agree. Nothing was written." });
121
+ }
122
+ try {
123
+ await emit(run.subarray(0, take));
124
+ }
125
+ finally {
126
+ // ⚠ Zeroed as soon as it has been passed on, failure included. A sink that kept the array
127
+ // instead of copying would find zeroes — which is why its contract says not to.
128
+ run.fill(0);
129
+ }
130
+ taken += take;
131
+ left -= take;
132
+ }
133
+ try {
134
+ opener.finish();
135
+ }
136
+ catch {
137
+ throw refuse();
138
+ }
139
+ }
140
+ finally {
141
+ opener.free();
142
+ }
143
+ return taken;
144
+ }
@@ -0,0 +1,213 @@
1
+ // Where a downloaded file's plaintext goes WHILE it is still being produced.
2
+ //
3
+ // ⛔ THE PROMISE THAT WAS KEPT BY BUFFERING IS NOW KEPT HERE. Until now `download.ts` held every
4
+ // decrypted byte, checked the whole-file digest the account had sealed, and only then handed a
5
+ // finished array to a caller that wrote it out. That is why a half-right file never appeared
6
+ // under a real name. It also meant a file that could be uploaded — uploads go part by part and
7
+ // are bounded by one part — could not be brought back on a machine smaller than the file.
8
+ // Streaming moves bytes before the last chunk is checked, so the promise has to be kept by the
9
+ // DESTINATION instead: a file lands under a temporary name and is renamed into place only after
10
+ // the digest matches, and a failure takes the temporary file with it.
11
+ //
12
+ // ⛔ A SINK MUST NOT KEEP THE ARRAY IT IS HANDED. The caller zeroes each run of plaintext as soon
13
+ // as `write` resolves, so a sink that stored the reference would hold a buffer full of zeroes
14
+ // and write them out. Copy, or finish with the bytes before resolving.
15
+ import { randomBytes } from "node:crypto";
16
+ import { createWriteStream, existsSync } from "node:fs";
17
+ import { rename, rm } from "node:fs/promises";
18
+ import { basename, dirname, join } from "node:path";
19
+ import { NmtsError } from "./errors.js";
20
+ import { handOver } from "./stdout.js";
21
+ /** The one refusal text for a destination that is already occupied. Shared by every command. */
22
+ function alreadyThere(destination) {
23
+ return new NmtsError(`${destination} already exists.`, {
24
+ exitCode: 4,
25
+ nextStep: "Nothing was written. Pass --out to choose another name, or --force to replace it.",
26
+ });
27
+ }
28
+ /**
29
+ * Write to a file, through a temporary name beside it, renamed into place only when the whole
30
+ * file has been proved.
31
+ *
32
+ * ⛔ THE TEMPORARY FILE IS IN THE SAME DIRECTORY AS THE DESTINATION, not in a system temporary
33
+ * directory, and that is the whole mechanism: `rename` is atomic only within one filesystem,
34
+ * and only an atomic rename guarantees that a reader either sees nothing under the real name or
35
+ * sees the finished file. A temporary directory can be — and on the machines this runs on
36
+ * usually is — a different filesystem, where the same call becomes copy-then-delete and a
37
+ * reader can catch the copy half done.
38
+ *
39
+ * ⛔ IT IS CREATED 0600 AND THE RENAME KEEPS THAT. The file is somebody's plaintext; it must not
40
+ * be readable by other accounts on the machine for the seconds it takes to download, any more
41
+ * than it may be afterwards.
42
+ */
43
+ export function fileSink(destination, options) {
44
+ // ⛔ Asked BEFORE the download rather than at the end, which is where the old code found out.
45
+ // Fetching a file to discover it had nowhere to go spent somebody's time and the storage
46
+ // network's bandwidth for a refusal that was knowable up front.
47
+ if (!options.force && existsSync(destination))
48
+ throw alreadyThere(destination);
49
+ const temporary = join(dirname(destination), `.${basename(destination)}.nmts-${randomBytes(6).toString("hex")}.part`);
50
+ let stream = null;
51
+ let failure = null;
52
+ let renamed = false;
53
+ // ⚠ Created on the first write rather than up front: a download that fails before it produces a
54
+ // byte — a wrong key, a part on a network this build cannot read — then leaves no file at all,
55
+ // not even one that appears and disappears.
56
+ const opened = () => {
57
+ if (stream !== null)
58
+ return stream;
59
+ const fresh = createWriteStream(temporary, { flags: "wx", mode: 0o600 });
60
+ // ⛔ An 'error' with no listener is thrown out of the event loop and kills the process, which
61
+ // would turn "the disk is full" into a crash with no next step. Kept, and reported by the
62
+ // next call that can report it.
63
+ fresh.on("error", (error) => {
64
+ failure = error;
65
+ });
66
+ stream = fresh;
67
+ return fresh;
68
+ };
69
+ const closed = async () => {
70
+ const open = stream;
71
+ if (open === null)
72
+ return;
73
+ stream = null;
74
+ // ⚠ Waits for 'close', not 'finish': a stream that failed to open never finishes, and waiting
75
+ // for the wrong event there is a hang rather than an error.
76
+ await new Promise((resolve) => {
77
+ if (open.closed) {
78
+ resolve();
79
+ return;
80
+ }
81
+ open.once("close", () => resolve());
82
+ open.end();
83
+ });
84
+ };
85
+ const abandon = async () => {
86
+ if (renamed)
87
+ return;
88
+ try {
89
+ await closed();
90
+ }
91
+ catch {
92
+ // Nothing to do about it: the file below is going away either way.
93
+ }
94
+ await rm(temporary, { force: true }).catch(() => undefined);
95
+ };
96
+ return {
97
+ // Nothing to refuse: what a disk cannot take, the write itself reports, and the temporary
98
+ // file means a disk that fills up leaves no file under the name somebody asked for.
99
+ expect: () => undefined,
100
+ write: async (bytes) => {
101
+ const target = opened();
102
+ await new Promise((resolve, reject) => {
103
+ const known = failure;
104
+ if (known !== null) {
105
+ reject(known);
106
+ return;
107
+ }
108
+ target.write(bytes, (error) => {
109
+ if (error === undefined || error === null)
110
+ resolve();
111
+ else
112
+ reject(error);
113
+ });
114
+ });
115
+ },
116
+ commit: async () => {
117
+ // A file of zero bytes is still a file, so the stream is opened even if nothing was written.
118
+ opened();
119
+ await closed();
120
+ const known = failure;
121
+ if (known !== null) {
122
+ await abandon();
123
+ throw new NmtsError(`${destination} could not be written: ${known.message}`, {
124
+ exitCode: 1,
125
+ nextStep: "Nothing was left under that name. The download itself was fine — this is the disk.",
126
+ });
127
+ }
128
+ // ⛔ Asked a second time, as late as possible. The first check was before the download; a
129
+ // file that appeared in the meantime is somebody else's, and rename would silently
130
+ // replace it. The window is now the width of one system call rather than a download.
131
+ if (!options.force && existsSync(destination)) {
132
+ await abandon();
133
+ throw alreadyThere(destination);
134
+ }
135
+ await rename(temporary, destination);
136
+ renamed = true;
137
+ return true;
138
+ },
139
+ abandon,
140
+ };
141
+ }
142
+ /**
143
+ * How much plaintext `--out -` will hold before it hands anything over. One part's worth.
144
+ *
145
+ * ⛔ NOT AN ARBITRARY NUMBER: it is the upload path's default part size, which is the most memory
146
+ * this tool has ever asked for. Keeping the stdout ceiling there means the whole tool's bound
147
+ * is one part plus one chunk whichever direction the bytes are going.
148
+ */
149
+ export const STDOUT_HOLD_LIMIT = 64 * 2 ** 20;
150
+ /**
151
+ * Hand the whole file to whatever is reading stdout — after it has been proved, not during.
152
+ *
153
+ * ⛔ THERE IS NO RENAME ON A PIPE, so the trick the file path uses is not available: a byte handed
154
+ * to a reader cannot be taken back, and a reader that has already consumed half a file cannot
155
+ * be told afterwards that the half was wrong. An exit code does not fix that — `nmts get x
156
+ * --out - > y` leaves the truncated bytes in `y` whatever the exit code says, and an agent
157
+ * reading a pipe usually acts on what it read. So this branch keeps the ORIGINAL guarantee
158
+ * rather than the original size: the file is held, checked, and only then handed over, exactly
159
+ * as before streaming existed.
160
+ *
161
+ * ⛔ WHICH MEANS IT HAS TO REFUSE SOMEWHERE, AND IT REFUSES OUT LOUD AND EARLY. Above the ceiling
162
+ * the answer is a refusal naming `--out <name>`, which streams and is checked before the file
163
+ * appears — not a silent trade of the guarantee for the size, and not the out-of-memory crash
164
+ * that this used to be. `put` already refuses this way rather than discovering a limit halfway.
165
+ *
166
+ * ⚠ Every protection this branch already had is still here and still applies to the whole file:
167
+ * bytes a terminal would act on are refused (`readableOnATerminal`), a reader that closed the
168
+ * pipe first is an ordinary end and not a failure, and any other write failure throws.
169
+ */
170
+ export function stdoutSink(to, limit = STDOUT_HOLD_LIMIT) {
171
+ const held = [];
172
+ let total = 0;
173
+ const tooLarge = (size) => new NmtsError(`This file is ${size} bytes, and \`--out -\` hands over at most ${limit}.`, {
174
+ exitCode: 4,
175
+ nextStep: "Nothing was written and nothing was sent. A pipe cannot be taken back, so this mode " +
176
+ "proves the whole file before it sends a byte, which means holding it. Use `--out <name>` " +
177
+ "— that writes any size, and the file only appears under that name once it has been checked.",
178
+ });
179
+ const forget = () => {
180
+ for (const run of held)
181
+ run.fill(0);
182
+ held.length = 0;
183
+ };
184
+ return {
185
+ expect: (size) => {
186
+ if (size > limit)
187
+ throw tooLarge(size);
188
+ },
189
+ write: async (bytes) => {
190
+ // ⚠ Copied, because the caller zeroes what it handed over as soon as this resolves.
191
+ total += bytes.length;
192
+ if (total > limit) {
193
+ forget();
194
+ throw tooLarge(total);
195
+ }
196
+ held.push(new Uint8Array(bytes));
197
+ },
198
+ commit: async () => {
199
+ const whole = new Uint8Array(total);
200
+ let at = 0;
201
+ for (const run of held) {
202
+ whole.set(run, at);
203
+ at += run.length;
204
+ }
205
+ forget();
206
+ // ⚠ NOT zeroed afterwards, deliberately: `ByteDestination` promises only that the bytes have
207
+ // been handed over, and a destination is entitled to keep the array it was given — the
208
+ // tests use one that does. Wiping it here would hand a reader a buffer of zeroes.
209
+ return await handOver(whole, to);
210
+ },
211
+ abandon: async () => forget(),
212
+ };
213
+ }