@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.
- package/AGENTS.md +591 -0
- package/LICENSE +202 -0
- package/LICENSING.md +49 -0
- package/README.ko.md +641 -0
- package/README.md +657 -0
- package/dist/account-proof.js +78 -0
- package/dist/account.js +75 -0
- package/dist/api-key.js +310 -0
- package/dist/api.js +271 -0
- package/dist/args.js +137 -0
- package/dist/artifact-about.js +70 -0
- package/dist/autonomy.js +98 -0
- package/dist/code-access.js +76 -0
- package/dist/code-vault.js +233 -0
- package/dist/collision.js +133 -0
- package/dist/commands/balance.js +93 -0
- package/dist/commands/consent.js +77 -0
- package/dist/commands/create.js +356 -0
- package/dist/commands/env.js +136 -0
- package/dist/commands/expiring.js +167 -0
- package/dist/commands/extend.js +236 -0
- package/dist/commands/get.js +130 -0
- package/dist/commands/kit.js +136 -0
- package/dist/commands/listfile.js +105 -0
- package/dist/commands/login.js +335 -0
- package/dist/commands/logout.js +33 -0
- package/dist/commands/ls.js +202 -0
- package/dist/commands/marks.js +176 -0
- package/dist/commands/mcp.js +139 -0
- package/dist/commands/mode.js +54 -0
- package/dist/commands/on-collision.js +45 -0
- package/dist/commands/organise.js +296 -0
- package/dist/commands/public-code.js +89 -0
- package/dist/commands/pull.js +185 -0
- package/dist/commands/push.js +278 -0
- package/dist/commands/put.js +282 -0
- package/dist/commands/rebuild.js +160 -0
- package/dist/commands/receive.js +125 -0
- package/dist/commands/recovery-list.js +104 -0
- package/dist/commands/recovery.js +291 -0
- package/dist/commands/s3.js +241 -0
- package/dist/commands/settings.js +28 -0
- package/dist/commands/share.js +276 -0
- package/dist/commands/sweep.js +240 -0
- package/dist/commands/trash.js +288 -0
- package/dist/commands/trial.js +264 -0
- package/dist/commands/update.js +169 -0
- package/dist/commands/usage.js +84 -0
- package/dist/commands/verify.js +331 -0
- package/dist/commands/wallet.js +122 -0
- package/dist/commands/whoami.js +33 -0
- package/dist/consent.js +200 -0
- package/dist/credentials.js +311 -0
- package/dist/crypto-surface.js +54 -0
- package/dist/crypto.js +137 -0
- package/dist/download-part.js +144 -0
- package/dist/download-sink.js +213 -0
- package/dist/download.js +163 -0
- package/dist/drive-paths.js +170 -0
- package/dist/environment.js +190 -0
- package/dist/errors.js +71 -0
- package/dist/exit.js +107 -0
- package/dist/expiry.js +115 -0
- package/dist/extend-chain.js +137 -0
- package/dist/extend-plan.js +149 -0
- package/dist/extend-sign.js +130 -0
- package/dist/guards.js +15 -0
- package/dist/help.js +192 -0
- package/dist/human-check.js +54 -0
- package/dist/item-trash.js +25 -0
- package/dist/kit-file.js +133 -0
- package/dist/list-file.js +71 -0
- package/dist/list-view-find.js +71 -0
- package/dist/list-view-order.js +96 -0
- package/dist/main.js +394 -0
- package/dist/manifest-create.js +81 -0
- package/dist/manifest-write.js +244 -0
- package/dist/manifest.js +213 -0
- package/dist/mark-render.js +45 -0
- package/dist/mcp-args.js +154 -0
- package/dist/mcp-tools/context.js +50 -0
- package/dist/mcp-tools/files.js +133 -0
- package/dist/mcp-tools/organise.js +128 -0
- package/dist/mcp-tools/reads.js +89 -0
- package/dist/mcp-tools/share.js +61 -0
- package/dist/mcp.js +142 -0
- package/dist/net-retry.js +84 -0
- package/dist/network.js +43 -0
- package/dist/notice.js +45 -0
- package/dist/product.js +38 -0
- package/dist/progress.js +110 -0
- package/dist/prompt.js +145 -0
- package/dist/rebuild.js +255 -0
- package/dist/recovery-assemble.js +148 -0
- package/dist/recovery-build.js +260 -0
- package/dist/recovery-map-file.js +114 -0
- package/dist/recovery-map.js +191 -0
- package/dist/recovery-release.js +229 -0
- package/dist/recovery-seq.js +63 -0
- package/dist/recovery-source.js +143 -0
- package/dist/registration.js +83 -0
- package/dist/s3/listing.js +134 -0
- package/dist/s3/multipart.js +104 -0
- package/dist/s3/response-sink.js +41 -0
- package/dist/s3/same-file.js +117 -0
- package/dist/s3/server.js +252 -0
- package/dist/s3/sigv4.js +168 -0
- package/dist/s3/staging.js +87 -0
- package/dist/s3/xml.js +80 -0
- package/dist/safe-path.js +89 -0
- package/dist/seal.js +206 -0
- package/dist/secret-reader.js +112 -0
- package/dist/server.js +33 -0
- package/dist/session.js +49 -0
- package/dist/setup-questions.js +31 -0
- package/dist/share.js +195 -0
- package/dist/shared/lib/crypto/size-padding.js +126 -0
- package/dist/shared/lib/drive/manifest-codec.js +287 -0
- package/dist/shared/lib/drive/manifest-index.js +235 -0
- package/dist/shared/lib/drive/manifest-ops.js +267 -0
- package/dist/shared/lib/drive/manifest-settings.js +65 -0
- package/dist/shared/lib/drive/name-conflict.js +121 -0
- package/dist/shared/lib/drive/unique-name.js +48 -0
- package/dist/shared/lib/extend/epochs.js +256 -0
- package/dist/shared/lib/net/retry-budget.js +95 -0
- package/dist/shared/lib/share/shared-file-info.js +65 -0
- package/dist/shared/lib/storage-network.js +64 -0
- package/dist/shared/lib/upload/part-plan.js +28 -0
- package/dist/stdout.js +109 -0
- package/dist/trash-sweep.js +123 -0
- package/dist/units.js +19 -0
- package/dist/update-check.js +164 -0
- package/dist/update-source.js +143 -0
- package/dist/upload-api.js +98 -0
- package/dist/upload-file.js +242 -0
- package/dist/upload-price.js +117 -0
- package/dist/upload-steps.js +111 -0
- package/dist/upload-store.js +252 -0
- package/dist/upload-wire.js +25 -0
- package/dist/upload.js +294 -0
- package/dist/usage-report.js +54 -0
- package/dist/wallet-chain.js +76 -0
- package/dist/wallet.js +209 -0
- package/dist/walrus-write.js +188 -0
- package/dist/walrus.js +156 -0
- package/package.json +56 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
- package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
- package/vendor/nmts-crypto/package.json +17 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// `nmts recovery` — fetching the standalone recovery program, checked, into a file you name.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY IT EXISTS. The recovery program is the answer to "NMTS is gone and I still have my
|
|
4
|
+
// account code": it reads files back off the public storage network on its own, talking to no
|
|
5
|
+
// NMTS server. Until its release workflow existed, getting it meant installing a Rust toolchain
|
|
6
|
+
// and building it — a fair ask of somebody auditing it, an unfair one of somebody who has just
|
|
7
|
+
// lost access to their files, and an impossible one for an agent working in a terminal.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ IT NEVER OVERWRITES AND IT NEVER INSTALLS ITSELF. One file, in the directory the caller
|
|
10
|
+
// named, under the name the release published it as; a name that is taken is a refusal unless
|
|
11
|
+
// `--force` says otherwise; and the full path is printed. Nothing is copied onto a PATH
|
|
12
|
+
// directory, nothing is added to a shell profile, nothing on this machine is made to point at
|
|
13
|
+
// it. A program that quietly drops an executable somewhere the shell will find it has not saved
|
|
14
|
+
// anybody a step — it has taken a decision that was not its to take.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ THE BYTES ARE CHECKED BEFORE THEY ARE MADE RUNNABLE. `SHA256SUMS` is fetched first, the tag
|
|
17
|
+
// the release resolved to is taken out of that request's redirects, and the executable is then
|
|
18
|
+
// asked for from THAT tag — never from "latest" a second time, which could answer from a
|
|
19
|
+
// release published in between. The file is written without an executable bit, hashed, and only
|
|
20
|
+
// then made runnable; a hash that does not match leaves nothing on the disk at all.
|
|
21
|
+
//
|
|
22
|
+
// ⛔ IT IS NOT AN MCP TOOL, AND MUST NOT BECOME ONE. `commands/mcp.ts` offers a model four things:
|
|
23
|
+
// who the account is, what is in it, one file out and one file in. Downloading an executable
|
|
24
|
+
// and setting its executable bit is not a step a model takes on somebody's behalf — the person
|
|
25
|
+
// who runs this has to be the person who decided to have it.
|
|
26
|
+
//
|
|
27
|
+
// ⚠ WHAT IT ASKS OF THE NETWORK IS NOT THE NMTS API. It talks to the source-hosting site the
|
|
28
|
+
// recovery program is published on, and to nothing else; no account code, no API key and no
|
|
29
|
+
// session is involved, and the command works signed out.
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
import { chmodSync, existsSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
33
|
+
import { NmtsError } from "../errors.js";
|
|
34
|
+
import { assetUrl, buildFromSource, CHECK_DOES_NOT_PROVE, CHECK_PROVES, executableFor, hashFromSums, publishedLabels, RECOVERY_TOOL, RECOVERY_TOOL_URL, SUMS_FILE, sumsUrl, tagFromChain, wrapText, } from "../recovery-release.js";
|
|
35
|
+
/** A download that stalls is a download that failed, and this one is a few megabytes. */
|
|
36
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
37
|
+
/**
|
|
38
|
+
* How many hops one address may take.
|
|
39
|
+
*
|
|
40
|
+
* ⚠ Two are ordinary — "latest" to the tagged address, and that to wherever the bytes are served
|
|
41
|
+
* from. More than a handful is a loop, and a loop with no cap is a command that never returns.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_REDIRECTS = 5;
|
|
44
|
+
/** `SHA256SUMS` lists five short lines. Anything approaching this is not that file. */
|
|
45
|
+
const MAX_SUMS_BYTES = 1024 * 1024;
|
|
46
|
+
/**
|
|
47
|
+
* A ceiling on the executable.
|
|
48
|
+
*
|
|
49
|
+
* ⛔ THE WHOLE FILE IS HELD IN MEMORY, because it has to be hashed before any of it is allowed to
|
|
50
|
+
* become runnable. That makes "how big is it" a question this command has to answer rather than
|
|
51
|
+
* discover: the published ones are single-digit megabytes, and this is ten times over.
|
|
52
|
+
*/
|
|
53
|
+
const MAX_EXECUTABLE_BYTES = 128 * 1024 * 1024;
|
|
54
|
+
export async function recovery(options = {}) {
|
|
55
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
56
|
+
const source = options.source ?? RECOVERY_TOOL_URL;
|
|
57
|
+
const platform = options.platform ?? process.platform;
|
|
58
|
+
const arch = options.arch ?? process.arch;
|
|
59
|
+
const wanted = executableFor(platform, arch);
|
|
60
|
+
if (wanted === null)
|
|
61
|
+
throw noExecutableFor(platform, arch, source);
|
|
62
|
+
// ⛔ ASKED BEFORE ANYTHING IS DOWNLOADED. A taken name is a refusal either way, and finding out
|
|
63
|
+
// after several megabytes have crossed somebody's connection helps nobody. The reservation
|
|
64
|
+
// that actually makes it safe is the `wx` write below — this is the fast half of it.
|
|
65
|
+
const destination = destinationFor(options.out, wanted.asset);
|
|
66
|
+
if (options.force !== true && existsSync(destination))
|
|
67
|
+
throw alreadyThere(destination, "downloaded");
|
|
68
|
+
const sums = await fetched(sumsUrl(source), MAX_SUMS_BYTES, SUMS_FILE);
|
|
69
|
+
// ⛔ THE TAG COMES OUT OF THE REQUEST THAT WAS JUST ANSWERED. Asking for "latest" a second time
|
|
70
|
+
// would be a second question, and two questions can get two answers: a release published
|
|
71
|
+
// between them would hand over an executable that the sums in hand do not describe, and the
|
|
72
|
+
// mismatch would look like a corrupt download rather than what it is.
|
|
73
|
+
const tag = tagFromChain(sums.chain, SUMS_FILE);
|
|
74
|
+
if (tag === null)
|
|
75
|
+
throw unresolvedRelease(source);
|
|
76
|
+
const lookup = hashFromSums(new TextDecoder().decode(sums.bytes), wanted.asset);
|
|
77
|
+
if (!lookup.found)
|
|
78
|
+
throw noLineFor(wanted.asset, tag, lookup.why);
|
|
79
|
+
const from = assetUrl(source, tag, wanted.asset);
|
|
80
|
+
const got = await fetched(from, MAX_EXECUTABLE_BYTES, wanted.asset);
|
|
81
|
+
// ⛔ 0600 AND NOT EXECUTABLE. The bit that makes this runnable goes on further down, after the
|
|
82
|
+
// hash matched — between here and there the file on the disk is bytes nobody has checked, and
|
|
83
|
+
// a run interrupted in that window must not leave something a shell will start.
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(destination, got.bytes, { flag: options.force === true ? "w" : "wx", mode: 0o600 });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof Error && Reflect.get(error, "code") === "EEXIST") {
|
|
89
|
+
throw alreadyThere(destination, "written");
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
const digest = createHash("sha256").update(got.bytes).digest("hex");
|
|
94
|
+
if (digest !== lookup.hash) {
|
|
95
|
+
// ⛔ NOTHING IS LEFT BEHIND. A file that failed this check is not a partial download to resume
|
|
96
|
+
// or an artefact to inspect — it is bytes of unknown provenance under the name of a program
|
|
97
|
+
// somebody is about to run when everything else has already gone wrong.
|
|
98
|
+
//
|
|
99
|
+
// ⚠ WITH `--force` THAT MEANS THE OLD FILE IS GONE TOO, and it is the honest outcome rather
|
|
100
|
+
// than an oversight: `--force` said this name was the caller's to replace, and the two
|
|
101
|
+
// things that could be left here are a file they asked to be rid of and one nothing
|
|
102
|
+
// checked. Without `--force` this branch is only ever reached for a file this run made.
|
|
103
|
+
rmSync(destination, { force: true });
|
|
104
|
+
throw doesNotMatch(wanted.asset, tag, lookup.hash, digest);
|
|
105
|
+
}
|
|
106
|
+
// ⚠ THE HOST'S PLATFORM DECIDES, not the one being fetched for: the bit is a property of the
|
|
107
|
+
// filesystem being written to. Windows has no executable bit and `chmod` there is a no-op at
|
|
108
|
+
// best, so it is not asked for.
|
|
109
|
+
if (process.platform !== "win32")
|
|
110
|
+
chmodSync(destination, 0o700);
|
|
111
|
+
if (options.json === true) {
|
|
112
|
+
say(JSON.stringify({
|
|
113
|
+
program: RECOVERY_TOOL,
|
|
114
|
+
platform: wanted.label,
|
|
115
|
+
asset: wanted.asset,
|
|
116
|
+
release: tag,
|
|
117
|
+
from,
|
|
118
|
+
writtenTo: destination,
|
|
119
|
+
bytes: got.bytes.length,
|
|
120
|
+
sha256: digest,
|
|
121
|
+
// ⛔ BOTH SENTENCES REACH A READER THAT ONLY PARSES JSON. An agent handing this file to
|
|
122
|
+
// somebody has to be able to say what was and was not established, in the same words.
|
|
123
|
+
proves: CHECK_PROVES,
|
|
124
|
+
doesNotProve: CHECK_DOES_NOT_PROVE,
|
|
125
|
+
}));
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
say(`Wrote ${destination}`);
|
|
129
|
+
say(``);
|
|
130
|
+
say(` ${RECOVERY_TOOL} for ${wanted.label}, from release ${tag}`);
|
|
131
|
+
say(` ${from}`);
|
|
132
|
+
say(` sha256 ${digest}`);
|
|
133
|
+
say(``);
|
|
134
|
+
for (const line of wrapText(CHECK_PROVES))
|
|
135
|
+
say(` ${line}`);
|
|
136
|
+
for (const line of wrapText(CHECK_DOES_NOT_PROVE))
|
|
137
|
+
say(` ${line}`);
|
|
138
|
+
say(``);
|
|
139
|
+
for (const line of wrapText(`Nothing was installed. The file is where you see it and nowhere else — no copy was put on ` +
|
|
140
|
+
`your PATH, and nothing on this machine was pointed at it. Run it with \`--help\` to see ` +
|
|
141
|
+
`what it does.`)) {
|
|
142
|
+
say(` ${line}`);
|
|
143
|
+
}
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
/** Where the file goes: the given file name, inside the given directory, or this directory. */
|
|
147
|
+
function destinationFor(out, asset) {
|
|
148
|
+
if (out === undefined)
|
|
149
|
+
return join(process.cwd(), asset);
|
|
150
|
+
const target = isAbsolute(out) ? out : resolve(process.cwd(), out);
|
|
151
|
+
// A directory that exists means "put it in here under its published name"; anything else is the
|
|
152
|
+
// name to write. Guessing the other way round would write a file called `bin`.
|
|
153
|
+
if (existsSync(target) && statSync(target).isDirectory())
|
|
154
|
+
return join(target, asset);
|
|
155
|
+
return target;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Fetch one address, following redirects by hand.
|
|
159
|
+
*
|
|
160
|
+
* ⛔ BY HAND BECAUSE THE CHAIN IS THE ANSWER. `redirect: "follow"` reports only where a request
|
|
161
|
+
* ended, and where this one ends carries no release tag — the hop that names the tag is in the
|
|
162
|
+
* middle. Following it here is what lets the executable be asked for from the same release as
|
|
163
|
+
* the sums, which is the whole point of checking them.
|
|
164
|
+
*/
|
|
165
|
+
async function fetched(from, cap, what) {
|
|
166
|
+
const chain = [from];
|
|
167
|
+
let current = from;
|
|
168
|
+
const wasSecure = new URL(from).protocol === "https:";
|
|
169
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await fetch(current, {
|
|
173
|
+
redirect: "manual",
|
|
174
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
throw new NmtsError(`${what} could not be fetched: ${because(error)}`, {
|
|
179
|
+
exitCode: 1,
|
|
180
|
+
nextStep: `Nothing was written. This talks to the site the recovery program is published on, not ` +
|
|
181
|
+
`to NMTS — check that this machine can reach it, and try again.`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (response.status >= 300 && response.status < 400) {
|
|
185
|
+
const location = response.headers.get("location");
|
|
186
|
+
// The body of a redirect is not part of the answer; reading it releases the connection.
|
|
187
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
188
|
+
if (location === null || location === "") {
|
|
189
|
+
throw new NmtsError(`${what} was redirected to nowhere (${response.status}).`, {
|
|
190
|
+
exitCode: 1,
|
|
191
|
+
nextStep: `Nothing was written. The release page answered in a shape this cannot follow.`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const next = new URL(location, current);
|
|
195
|
+
// ⛔ A REDIRECT MAY NOT DOWNGRADE THE CONNECTION. A request that started encrypted and is
|
|
196
|
+
// talked down to a plain one hands whoever is in the middle the executable to write.
|
|
197
|
+
if (wasSecure && next.protocol !== "https:") {
|
|
198
|
+
throw new NmtsError(`${what} was redirected off an encrypted connection.`, {
|
|
199
|
+
exitCode: 1,
|
|
200
|
+
nextStep: `Nothing was written. This refuses rather than fetching an executable in the clear.`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
current = next.toString();
|
|
204
|
+
chain.push(current);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new NmtsError(`${what} — the release answered ${response.status}.`, {
|
|
209
|
+
exitCode: 1,
|
|
210
|
+
nextStep: response.status === 404
|
|
211
|
+
? `Nothing was written. That file is not attached to the release this resolved to.`
|
|
212
|
+
: `Nothing was written. Try again; if it keeps answering this, the release page is the ` +
|
|
213
|
+
`place to look.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const declared = Number(response.headers.get("content-length"));
|
|
217
|
+
if (Number.isFinite(declared) && declared > cap)
|
|
218
|
+
throw tooBig(what, cap);
|
|
219
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
220
|
+
// ⚠ Asked again after the fact: the header above is a claim, and this is the measurement.
|
|
221
|
+
if (bytes.length > cap)
|
|
222
|
+
throw tooBig(what, cap);
|
|
223
|
+
return { bytes, chain };
|
|
224
|
+
}
|
|
225
|
+
throw new NmtsError(`${what} was redirected more than ${MAX_REDIRECTS} times.`, {
|
|
226
|
+
exitCode: 1,
|
|
227
|
+
nextStep: `Nothing was written.`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function because(error) {
|
|
231
|
+
return error instanceof Error ? error.message : "no answer";
|
|
232
|
+
}
|
|
233
|
+
function tooBig(what, cap) {
|
|
234
|
+
return new NmtsError(`${what} is larger than ${Math.round(cap / (1024 * 1024))} MiB.`, {
|
|
235
|
+
exitCode: 4,
|
|
236
|
+
nextStep: `Nothing was written. The published executables are a few megabytes; whatever answered is ` +
|
|
237
|
+
`not one of them.`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function alreadyThere(destination, verb) {
|
|
241
|
+
return new NmtsError(`${destination} is already there.`, {
|
|
242
|
+
exitCode: 4,
|
|
243
|
+
nextStep: `Nothing was ${verb}. Pass --out to choose another name, or --force to replace it.`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function noExecutableFor(platform, arch, source) {
|
|
247
|
+
const lines = [
|
|
248
|
+
`Nothing was downloaded. The release publishes one for each of these, and this machine ` +
|
|
249
|
+
`matches none of them:`,
|
|
250
|
+
``,
|
|
251
|
+
...publishedLabels().map((label) => ` ${label}`),
|
|
252
|
+
``,
|
|
253
|
+
`It builds from source on anything a Rust toolchain runs on:`,
|
|
254
|
+
``,
|
|
255
|
+
...buildFromSource(source).map((command) => ` ${command}`),
|
|
256
|
+
``,
|
|
257
|
+
`The source, and what it does, are at ${source}.`,
|
|
258
|
+
];
|
|
259
|
+
return new NmtsError(`No recovery program is published for ${platform} ${arch}.`, {
|
|
260
|
+
exitCode: 4,
|
|
261
|
+
nextStep: lines.join("\n"),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function unresolvedRelease(source) {
|
|
265
|
+
return new NmtsError(`The newest release did not resolve to a named one.`, {
|
|
266
|
+
exitCode: 4,
|
|
267
|
+
nextStep: `Nothing was written. This follows ${SUMS_FILE} to the release it belongs to so the ` +
|
|
268
|
+
`executable can be asked for from that same release, and the address it was handed does not ` +
|
|
269
|
+
`name one. The releases are listed at ${source}/releases.`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function noLineFor(asset, tag, why) {
|
|
273
|
+
const detail = why === "repeated"
|
|
274
|
+
? `${SUMS_FILE} in release ${tag} lists ${asset} more than once.`
|
|
275
|
+
: why === "malformed"
|
|
276
|
+
? `${SUMS_FILE} in release ${tag} lists ${asset} with something that is not a SHA-256 hash.`
|
|
277
|
+
: `${SUMS_FILE} in release ${tag} has no line for ${asset}.`;
|
|
278
|
+
return new NmtsError(detail, {
|
|
279
|
+
exitCode: 4,
|
|
280
|
+
nextStep: `Nothing was downloaded. Without a line to compare against there is nothing to check the ` +
|
|
281
|
+
`bytes with, and this does not hand over an executable it did not check.`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
function doesNotMatch(asset, tag, expected, got) {
|
|
285
|
+
return new NmtsError(`${asset} is not the file release ${tag} published.`, {
|
|
286
|
+
exitCode: 4,
|
|
287
|
+
nextStep: `The downloaded file was deleted. ${SUMS_FILE} says ${expected}; what arrived hashes to ` +
|
|
288
|
+
`${got}. Run this again — a download can be cut short — and if it says the same thing twice, ` +
|
|
289
|
+
`do not run the file.`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// `nmts s3` — serve this account's drive to any program that speaks S3.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ WHY THIS EXISTS. Backup programs, sync tools and agent frameworks already know how to talk to
|
|
4
|
+
// S3. They do not know how to talk to this. Rather than ask every one of them to learn, the tool
|
|
5
|
+
// speaks the protocol they already speak, on this machine, where the account code already is.
|
|
6
|
+
//
|
|
7
|
+
// ⛔ WHAT IT IS NOT. It is not a bridge to the internet and cannot be made into one: the address it
|
|
8
|
+
// binds is loopback and there is no option to change that (`s3/server.ts` says why). Nothing is
|
|
9
|
+
// stored: the access key it prints is made fresh every time it starts and dies with it.
|
|
10
|
+
//
|
|
11
|
+
// ⛔ WRITING IS BEHIND THE SPENDING AGREEMENT, AND SAYS SO WHEN IT IS NOT THERE. Uploading costs
|
|
12
|
+
// credits, and spending is one of the three things this tool asks a person about once per
|
|
13
|
+
// machine -- and a gateway cannot ask, because its caller is a program and its stdin is not a
|
|
14
|
+
// terminal. So the agreement has to exist before it starts: without it the drive is served read
|
|
15
|
+
// only and every write is refused with the sentence naming the command that grants it.
|
|
16
|
+
import { createWriteStream } from "node:fs";
|
|
17
|
+
import { mkdir as makeDir, rm as removeFile, stat } from "node:fs/promises";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { pipeline } from "node:stream/promises";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { isGranted } from "../consent.js";
|
|
23
|
+
import { createStaging } from "../s3/staging.js";
|
|
24
|
+
import { refusalFor, verdictForKey } from "../s3/same-file.js";
|
|
25
|
+
import { fetchFile } from "../download.js";
|
|
26
|
+
import { NmtsError } from "../errors.js";
|
|
27
|
+
import { ensureFolderPath } from "./organise.js";
|
|
28
|
+
import { put } from "./put.js";
|
|
29
|
+
import { rm } from "./trash.js";
|
|
30
|
+
import { readFileList } from "../manifest.js";
|
|
31
|
+
import { resolveNetwork } from "../network.js";
|
|
32
|
+
import { BINARY_NAME } from "../product.js";
|
|
33
|
+
import { openSession } from "../session.js";
|
|
34
|
+
import { BUCKET } from "../s3/listing.js";
|
|
35
|
+
import { BIND_ADDRESS, createGateway, newCredential } from "../s3/server.js";
|
|
36
|
+
/** MinIO's port, which is what most S3 tools already have in their examples. */
|
|
37
|
+
export const DEFAULT_PORT = 9000;
|
|
38
|
+
/**
|
|
39
|
+
* How long a file list may be reused before it is fetched again.
|
|
40
|
+
*
|
|
41
|
+
* ⛔ THERE IS A CACHE BECAUSE A SYNC IS THOUSANDS OF REQUESTS. Reading the list per request would
|
|
42
|
+
* mean a server round trip and a decryption for each one, so a listing of a large drive would
|
|
43
|
+
* take minutes and cost the account's rate budget. ⚠ It also means a file uploaded from another
|
|
44
|
+
* device can be up to this long in appearing here, which is the trade and is written in the
|
|
45
|
+
* tool's own words when it starts.
|
|
46
|
+
*/
|
|
47
|
+
export const LIST_CACHE_MS = 5_000;
|
|
48
|
+
function portOf(raw) {
|
|
49
|
+
if (raw === undefined)
|
|
50
|
+
return DEFAULT_PORT;
|
|
51
|
+
const port = Number(raw);
|
|
52
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
53
|
+
throw new NmtsError(`--port needs a number between 1 and 65535, not ${raw}.`, { exitCode: 2 });
|
|
54
|
+
}
|
|
55
|
+
return port;
|
|
56
|
+
}
|
|
57
|
+
export async function s3(options = {}) {
|
|
58
|
+
const say = options.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
59
|
+
const port = portOf(options.port);
|
|
60
|
+
const session = await openSession({ server: options.server, network: options.network });
|
|
61
|
+
const chain = resolveNetwork(session.server, session.network);
|
|
62
|
+
const credential = newCredential();
|
|
63
|
+
let cached = [];
|
|
64
|
+
let cachedAt = 0;
|
|
65
|
+
const entries = async () => {
|
|
66
|
+
if (Date.now() - cachedAt < LIST_CACHE_MS)
|
|
67
|
+
return cached;
|
|
68
|
+
const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
|
|
69
|
+
cached = list.manifest === null ? [] : list.manifest.entries;
|
|
70
|
+
cachedAt = Date.now();
|
|
71
|
+
return cached;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Where the pieces of a multipart upload wait until they are one file.
|
|
75
|
+
*
|
|
76
|
+
* ⛔ ONE DIRECTORY PER RUN, 0700, REMOVED WHEN THE COMMAND STOPS. Pieces are somebody's
|
|
77
|
+
* plaintext; leaving them in a shared temporary directory under a predictable name would put
|
|
78
|
+
* them where any other account on the machine could read them, for as long as the upload
|
|
79
|
+
* takes and afterwards.
|
|
80
|
+
*/
|
|
81
|
+
const stagingRoot = join(tmpdir(), `nmts-s3-${randomUUID()}`);
|
|
82
|
+
/**
|
|
83
|
+
* Store one local file at a drive key, making the folders above it if they are missing.
|
|
84
|
+
*
|
|
85
|
+
* ⛔ THE SAME-FILE QUESTION IS ANSWERED HERE AND NOWHERE ELSE.
|
|
86
|
+
* Both ways of uploading — one PUT, or pieces staged and joined — end in this
|
|
87
|
+
* function, so a rule written here cannot disagree with itself; written in the protocol layer
|
|
88
|
+
* it would have to be written twice, once for each, and the two would differ the first time
|
|
89
|
+
* one of them changed. What is compared is the plaintext's SHA-256 against the one this
|
|
90
|
+
* account sealed when the file was first stored.
|
|
91
|
+
*
|
|
92
|
+
* ⛔ IDENTICAL CONTENT IS NOT AN ERROR. Nothing is sent and nothing is charged, and the caller
|
|
93
|
+
* is told the upload finished — because the statement it was making, "that file is at that
|
|
94
|
+
* key", is true. Answering 409 there is what made every backup run fail on every file it had
|
|
95
|
+
* already stored, and a sync tool writes 409 down as a failure.
|
|
96
|
+
*/
|
|
97
|
+
const storeFile = async (key, path) => {
|
|
98
|
+
const at = key.lastIndexOf("/");
|
|
99
|
+
const folder = at < 0 ? undefined : key.slice(0, at);
|
|
100
|
+
const name = at < 0 ? key : key.slice(at + 1);
|
|
101
|
+
const verdict = await verdictForKey(await entries(), key, session.code, path);
|
|
102
|
+
// ⭐ Already there, byte for byte. This is the whole point: an unchanged file costs nothing to
|
|
103
|
+
// re-offer, so a backup that runs nightly stops paying for the nights nothing changed.
|
|
104
|
+
if (verdict === "same") {
|
|
105
|
+
say(`same ${key} — already stored, nothing sent`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (verdict !== "free")
|
|
109
|
+
throw refusalFor(verdict, key);
|
|
110
|
+
if (folder !== undefined && folder !== "")
|
|
111
|
+
await ensureFolderPath(session, folder);
|
|
112
|
+
await put(path, {
|
|
113
|
+
server: options.server,
|
|
114
|
+
network: options.network,
|
|
115
|
+
...(folder === undefined || folder === "" ? {} : { to: folder }),
|
|
116
|
+
name,
|
|
117
|
+
write: () => undefined,
|
|
118
|
+
});
|
|
119
|
+
cachedAt = 0;
|
|
120
|
+
};
|
|
121
|
+
// ⛔ WRITING IS OFF UNLESS THIS MACHINE ALREADY AGREED TO SPENDING. A gateway cannot ask: its
|
|
122
|
+
// caller is a program and its stdin is not a terminal. So the question is answered before it
|
|
123
|
+
// starts, and where the answer is no every write says so and nothing is charged.
|
|
124
|
+
const writable = isGranted("spend");
|
|
125
|
+
const server = createGateway({
|
|
126
|
+
credential,
|
|
127
|
+
source: {
|
|
128
|
+
entries,
|
|
129
|
+
...(writable
|
|
130
|
+
? {
|
|
131
|
+
write: {
|
|
132
|
+
// ⛔ THE BODY IS SPOOLED TO A FILE FIRST, 0600, and deleted whatever happens. The
|
|
133
|
+
// upload path reserves storage, cuts parts and seals them from a file, and giving
|
|
134
|
+
// it a socket instead would mean either holding whole uploads in memory or
|
|
135
|
+
// writing a second upload path — and a second upload path is a second place for
|
|
136
|
+
// "what if the reservation succeeds and the part fails" to be got right.
|
|
137
|
+
put: async (key, body, size) => {
|
|
138
|
+
await makeDir(stagingRoot, { recursive: true, mode: 0o700 });
|
|
139
|
+
const spool = join(stagingRoot, randomUUID());
|
|
140
|
+
try {
|
|
141
|
+
await pipeline(body, createWriteStream(spool, { mode: 0o600 }));
|
|
142
|
+
const written = (await stat(spool)).size;
|
|
143
|
+
if (written !== size) {
|
|
144
|
+
throw new NmtsError(`The upload said ${size} bytes and ${written} arrived. Nothing was stored.`);
|
|
145
|
+
}
|
|
146
|
+
await storeFile(key, spool);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await removeFile(spool, { force: true });
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
multipart: createStaging(stagingRoot, storeFile),
|
|
153
|
+
trash: async (object) => {
|
|
154
|
+
await rm([`/${object.key}`], {
|
|
155
|
+
server: options.server,
|
|
156
|
+
network: options.network,
|
|
157
|
+
write: () => undefined,
|
|
158
|
+
});
|
|
159
|
+
cachedAt = 0;
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
: {}),
|
|
164
|
+
// The real reader. The gateway takes it as a function so its own tests can be driven by a
|
|
165
|
+
// real S3 client without an account, a network or anybody's credits.
|
|
166
|
+
fetch: async (object, sink) => {
|
|
167
|
+
const wrapped = object.entry.dekWrapped;
|
|
168
|
+
if (wrapped === undefined)
|
|
169
|
+
throw new NmtsError("That entry has no key in the file list.");
|
|
170
|
+
await fetchFile({
|
|
171
|
+
base: session.server,
|
|
172
|
+
apiKey: session.apiKey,
|
|
173
|
+
accountCode: session.code,
|
|
174
|
+
itemId: object.entry.id,
|
|
175
|
+
size: object.size,
|
|
176
|
+
dekWrapped: wrapped,
|
|
177
|
+
contentHashCt: object.entry.contentHashCt,
|
|
178
|
+
chain,
|
|
179
|
+
sink,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
await new Promise((resolve, reject) => {
|
|
185
|
+
server.once("error", (error) => {
|
|
186
|
+
reject(error.code === "EADDRINUSE"
|
|
187
|
+
? new NmtsError(`Port ${port} on ${BIND_ADDRESS} is already taken.`, {
|
|
188
|
+
exitCode: 4,
|
|
189
|
+
nextStep: `Nothing was served. Pass --port with a free number.`,
|
|
190
|
+
})
|
|
191
|
+
: error);
|
|
192
|
+
});
|
|
193
|
+
server.listen(port, BIND_ADDRESS, resolve);
|
|
194
|
+
});
|
|
195
|
+
const endpoint = `http://${BIND_ADDRESS}:${port}`;
|
|
196
|
+
if (options.json === true) {
|
|
197
|
+
say(JSON.stringify({
|
|
198
|
+
endpoint,
|
|
199
|
+
bucket: BUCKET,
|
|
200
|
+
accessKeyId: credential.accessKeyId,
|
|
201
|
+
secretAccessKey: credential.secretAccessKey,
|
|
202
|
+
readOnly: !writable,
|
|
203
|
+
listCacheMs: LIST_CACHE_MS,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
say(` This account's drive is being served at ${endpoint}, to this machine only.`);
|
|
208
|
+
say(``);
|
|
209
|
+
say(` endpoint ${endpoint}`);
|
|
210
|
+
say(` bucket ${BUCKET}`);
|
|
211
|
+
say(` access key id ${credential.accessKeyId}`);
|
|
212
|
+
say(` secret key ${credential.secretAccessKey}`);
|
|
213
|
+
say(` region any — the signature carries whichever one the client used`);
|
|
214
|
+
say(``);
|
|
215
|
+
say(writable
|
|
216
|
+
? ` Listing, downloading, uploading and deleting all work. Uploading spends credits.`
|
|
217
|
+
: ` ⛔ READ ONLY — this machine has not agreed to spending, so uploads and deletes are` +
|
|
218
|
+
` refused. \`${BINARY_NAME} consent grant spend\`, run by the person whose account this` +
|
|
219
|
+
` is, changes that.`);
|
|
220
|
+
say(` ⚠ A file already in the drive is not replaced: uploading over one is refused, and a`);
|
|
221
|
+
say(` delete puts the old one in the trash for thirty days.`);
|
|
222
|
+
say(` ⛔ These credentials were made for this run and are stored nowhere. They stop working`);
|
|
223
|
+
say(` the moment this command does.`);
|
|
224
|
+
say(` ⚠ A file uploaded from another device can take ${LIST_CACHE_MS / 1000}s to appear here.`);
|
|
225
|
+
say(``);
|
|
226
|
+
say(` Press Ctrl-C to stop. \`${BINARY_NAME} s3 --json\` prints the same thing in one line.`);
|
|
227
|
+
}
|
|
228
|
+
await new Promise((resolve) => {
|
|
229
|
+
const stop = () => {
|
|
230
|
+
// Nothing half-uploaded outlives the command that was staging it.
|
|
231
|
+
void removeFile(stagingRoot, { recursive: true, force: true });
|
|
232
|
+
server.close(() => resolve());
|
|
233
|
+
// A client holding a connection open must not keep the process alive after Ctrl-C.
|
|
234
|
+
server.closeAllConnections();
|
|
235
|
+
};
|
|
236
|
+
process.once("SIGINT", stop);
|
|
237
|
+
process.once("SIGTERM", stop);
|
|
238
|
+
void options.until?.then(stop);
|
|
239
|
+
});
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The three commands that only read or write a setting on this machine.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ THEY ARE TOGETHER BECAUSE THEY ARE THE SAME KIND OF THING, not to save lines in `main.ts`.
|
|
4
|
+
// None of them touch the network, an account, or a file in the drive: each one prints what this
|
|
5
|
+
// machine is set to, or records a new answer. Keeping the dispatch for them in one place means
|
|
6
|
+
// the next setting has an obvious home, rather than another case in the middle of the commands
|
|
7
|
+
// that move money.
|
|
8
|
+
//
|
|
9
|
+
// ⚠ What each setting MEANS is not here — it is beside the setting itself (`consent.ts`,
|
|
10
|
+
// `autonomy.ts`, `collision.ts`), so a caller that needs the rule does not have to load a
|
|
11
|
+
// command to get it.
|
|
12
|
+
/** Is this one of the settings commands? */
|
|
13
|
+
export function isSettingsCommand(command) {
|
|
14
|
+
return command === "consent" || command === "mode" || command === "on-collision";
|
|
15
|
+
}
|
|
16
|
+
/** Run it. Only call this when `isSettingsCommand` said yes. */
|
|
17
|
+
export async function runSettings(command, args) {
|
|
18
|
+
if (command === "consent") {
|
|
19
|
+
const { consent } = await import("./consent.js");
|
|
20
|
+
return consent(args.operands[0], args.operands[1], { json: args.json });
|
|
21
|
+
}
|
|
22
|
+
if (command === "mode") {
|
|
23
|
+
const { mode } = await import("./mode.js");
|
|
24
|
+
return mode(args.operands[0], { json: args.json, accepted: args.iAcceptTheRisk });
|
|
25
|
+
}
|
|
26
|
+
const { onCollision } = await import("./on-collision.js");
|
|
27
|
+
return onCollision(args.operands[0], { json: args.json });
|
|
28
|
+
}
|