@camstack/addon-remote-storage 1.2.42 → 1.2.44

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.
@@ -0,0 +1,796 @@
1
+ import { c as BaseAddon, i as rearmIdleAbort, o as scheduleIdleAbort, s as storageProviderCapability } from "./shared-DsnRUNmJ.mjs";
2
+ import { randomUUID } from "node:crypto";
3
+ import * as path from "node:path";
4
+ import * as fsp from "node:fs/promises";
5
+ import * as os from "node:os";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ //#region src/providers/smb/smb-config-schema.ts
9
+ /**
10
+ * SMB / CIFS `storage-provider` config schema — drives the "Add location"
11
+ * wizard form in the admin UI.
12
+ *
13
+ * The shape follows the operator's own production Samba backup client
14
+ * (`scrypted-remote-backup`, `src/main.ts`): a server address, a target
15
+ * directory, username / password / domain, and a max-protocol override for
16
+ * the servers that need one pinned.
17
+ *
18
+ * `share` is split out of the address rather than asking the operator for a
19
+ * `//host/share` UNC string, because the pair `(host, share, basePath)` is the
20
+ * duplicate-location key the provider refuses on — a free-form UNC string
21
+ * would have to be re-parsed to ask that question, and would compare unequal
22
+ * for two spellings of the same share.
23
+ */
24
+ var SMB_CONFIG_SCHEMA = { sections: [{
25
+ id: "connection",
26
+ title: "Connection",
27
+ fields: [
28
+ {
29
+ type: "text",
30
+ key: "host",
31
+ label: "Server",
32
+ description: "SMB server hostname or IP (no slashes).",
33
+ required: true,
34
+ placeholder: "nas.local"
35
+ },
36
+ {
37
+ type: "text",
38
+ key: "share",
39
+ label: "Share",
40
+ description: "Share name as exported by the server, without slashes.",
41
+ required: true,
42
+ placeholder: "backups"
43
+ },
44
+ {
45
+ type: "number",
46
+ key: "port",
47
+ label: "Port",
48
+ description: "Leave empty for the default (445).",
49
+ min: 1,
50
+ max: 65535
51
+ },
52
+ {
53
+ type: "text",
54
+ key: "username",
55
+ label: "Username",
56
+ description: "Leave empty for a guest (anonymous) connection."
57
+ },
58
+ {
59
+ type: "password",
60
+ key: "password",
61
+ label: "Password",
62
+ showToggle: true
63
+ },
64
+ {
65
+ type: "text",
66
+ key: "domain",
67
+ label: "Domain / Workgroup",
68
+ description: "Optional. Required by some AD-joined servers."
69
+ },
70
+ {
71
+ type: "text",
72
+ key: "maxProtocol",
73
+ label: "Max protocol",
74
+ description: "Optional dialect ceiling, e.g. SMB3. Set this when a server negotiates a dialect the client mishandles.",
75
+ placeholder: "SMB3"
76
+ }
77
+ ]
78
+ }, {
79
+ id: "storage",
80
+ title: "Storage",
81
+ fields: [{
82
+ type: "text",
83
+ key: "basePath",
84
+ label: "Directory in share",
85
+ description: "Directory inside the share where files are written. Leave empty for the share root. Created if missing.",
86
+ placeholder: "camstack"
87
+ }]
88
+ }] };
89
+ //#endregion
90
+ //#region src/providers/smb/smb-client.ts
91
+ /**
92
+ * The seam between the SMB provider and `samba-client`.
93
+ *
94
+ * `samba-client@7` is a thin wrapper around the **`smbclient` binary** — it
95
+ * shells out through `execa` rather than speaking SMB in JavaScript. That is
96
+ * the whole reason it was chosen over the pure-JS options: every SMB client on
97
+ * npm is a fork of the abandoned `@marsaud/smb2` (last publish 2022), whereas
98
+ * `smbclient` is Samba's own tool, maintained by the people who define the
99
+ * protocol. It is the client the operator's `scrypted-remote-backup` plugin
100
+ * has been running in production against this same NAS.
101
+ *
102
+ * The cost is stated plainly because it does not go away: **the provider
103
+ * cannot work without the `smbclient` binary on PATH.** It is absent from the
104
+ * CamStack container image. The binary is obtained through the framework's
105
+ * manifest-declared system dependencies (`camstack.addons[].systemDependencies`
106
+ * → `installManifestSystemDeps`), which runs before this addon's capabilities
107
+ * register — and when that fails, {@link SmbBinaryUnavailableError} is what
108
+ * every method of the provider raises, so a missing binary reads as a REFUSAL
109
+ * and never as an empty share.
110
+ */
111
+ var execFileAsync = promisify(execFile);
112
+ /** The binary every operation ultimately runs. */
113
+ var SMB_BINARY = "smbclient";
114
+ /** How long the one-shot `command -v smbclient` probe may take. */
115
+ var PROBE_TIMEOUT_MS = 1e4;
116
+ /**
117
+ * Raised by every provider method when `smbclient` is not on PATH.
118
+ *
119
+ * A distinct type, not a generic `Error`, because the difference between
120
+ * "this share is empty" and "this provider cannot run" is the difference
121
+ * between a location that D293's delete guard will let an operator destroy
122
+ * and one it will not.
123
+ */
124
+ var SmbBinaryUnavailableError = class extends Error {
125
+ constructor() {
126
+ super(`smb-storage: the "${SMB_BINARY}" binary is not available on this node. It is declared in the addon manifest under camstack.addons[].systemDependencies and installed by the runner before this addon initialises; check the runner log for "system dependency install failed". Until it resolves, this provider refuses every operation — its locations are UNREACHABLE, which is not the same as empty.`);
127
+ this.name = "SmbBinaryUnavailableError";
128
+ }
129
+ };
130
+ /**
131
+ * Default factory — a real `samba-client`.
132
+ *
133
+ * Imported lazily so that merely LOADING this module (which the addon does at
134
+ * boot, before the binary check) never fails on a host where the package's
135
+ * own dependency tree is incomplete. A provider that cannot be constructed
136
+ * takes its three sibling providers down with it: they share
137
+ * `execution.group: "remote-storage"`, so one throw at import time respawns
138
+ * SFTP, S3 and WebDAV too.
139
+ */
140
+ async function defaultSmbClientFactory(options) {
141
+ const SambaClient = (await import("samba-client")).default;
142
+ return new SambaClient({
143
+ ...options,
144
+ maskCmd: true
145
+ });
146
+ }
147
+ /** Is `smbclient` on PATH? */
148
+ async function probeSmbBinary() {
149
+ try {
150
+ await execFileAsync("command", ["-v", SMB_BINARY], {
151
+ timeout: PROBE_TIMEOUT_MS,
152
+ shell: "/bin/sh"
153
+ });
154
+ return true;
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+ //#endregion
160
+ //#region src/providers/smb/smb-paths.ts
161
+ /**
162
+ * Path and identity helpers for the SMB provider.
163
+ *
164
+ * Two of these exist because `smbclient` is driven through a COMMAND STRING,
165
+ * not an argument vector: `samba-client` builds `-c "<verb> <args>"` and hands
166
+ * the whole thing to the binary, which parses it again. Anything that reaches
167
+ * a remote path from outside — a `relativePath` the caller chose — therefore
168
+ * has to be refused before it becomes part of that string.
169
+ */
170
+ /**
171
+ * Characters that would break out of, or corrupt, the `-c "<verb> <args>"`
172
+ * command string smbclient re-parses.
173
+ *
174
+ * Refusing is the only safe option here: there is no escaping convention this
175
+ * provider could apply that smbclient's own parser is guaranteed to undo the
176
+ * same way. Every path CamStack itself generates (archive names are a UUID and
177
+ * a timestamp) passes untouched, so the refusal costs nothing real.
178
+ */
179
+ var FORBIDDEN_PATH_CHARS = /["'`$;&|<>\r\n\\]/;
180
+ /** A path segment that would escape the location's own base. */
181
+ var TRAVERSAL_SEGMENT = /(^|\/)\.\.(\/|$)/;
182
+ /**
183
+ * Validate a caller-supplied relative path, returning it normalised to
184
+ * forward slashes.
185
+ */
186
+ function safeSmbRelativePath(relativePath) {
187
+ if (relativePath.length === 0) return "";
188
+ if (FORBIDDEN_PATH_CHARS.test(relativePath)) throw new Error("smb-storage: relativePath contains a character that cannot be passed to smbclient safely (one of \" ' ` $ ; & | < > backslash or a newline). Refusing rather than escaping it.");
189
+ if (TRAVERSAL_SEGMENT.test(relativePath)) throw new Error(`smb-storage: relativePath "${relativePath}" escapes the location base`);
190
+ return relativePath.replace(/^\/+/, "");
191
+ }
192
+ /** `//host/share` — the address `smbclient` takes. */
193
+ function smbAddress(host, share) {
194
+ return `//${host}/${share}`;
195
+ }
196
+ /**
197
+ * Normalise a base path inside the share for COMPARISON, not for use:
198
+ * lower-cased, slash-normalised, no leading or trailing slash. SMB is
199
+ * case-insensitive, so `Backups` and `backups` are the same directory.
200
+ */
201
+ function normalizedBasePath(basePath) {
202
+ return basePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").toLowerCase();
203
+ }
204
+ /**
205
+ * The identity of the PHYSICAL directory a location occupies.
206
+ *
207
+ * Two location records pointing at one directory is the hazard that has no
208
+ * equivalent for a local disk: each carries its own retention policy and each
209
+ * rewrites the destination's manifest, so the second one's sweep deletes
210
+ * archives the first one still lists — and a restore then fails on a file the
211
+ * manifest swears is present. Host and share are compared case-insensitively
212
+ * because SMB is.
213
+ */
214
+ function shareIdentity(input) {
215
+ return `${input.host.toLowerCase()}/${input.share.toLowerCase()}/${normalizedBasePath(input.basePath)}`;
216
+ }
217
+ /** Split a remote path into its directory segments, ignoring empties. */
218
+ function pathSegments(remotePath) {
219
+ return remotePath.split("/").filter((s) => s.length > 0);
220
+ }
221
+ //#endregion
222
+ //#region src/providers/smb/smb-provider.ts
223
+ /**
224
+ * SMB / CIFS `storage-provider` cap implementation — the fourth entry in
225
+ * `@camstack/addon-remote-storage`.
226
+ *
227
+ * ── Scope, and why it is narrow ────────────────────────────────────────────
228
+ * This provider serves `cap-mediated` storage kinds only, which today means
229
+ * `backups`. That is not a limitation of SMB; it is the declared
230
+ * service/location-kind constraint (D296). A kind whose owning service does
231
+ * raw `node:fs` on the string `storage.resolve` hands back cannot be served by
232
+ * ANY remote provider, and the orchestrator refuses the pairing at upsert time
233
+ * rather than letting it fail at the first write — where, for recordings, it
234
+ * would surface as a silent black window rather than an error.
235
+ *
236
+ * ── Why the I/O looks like this ────────────────────────────────────────────
237
+ * `smbclient` is a file-at-a-time tool: `put`, `get`, `del`, `rename`. There
238
+ * is no random-access write. So a chunked upload SPOOLS to a local temp file
239
+ * and transfers once on `finalizeUpload`, and a chunked download fetches once
240
+ * on `beginDownload` and serves ranges from the local copy. For the one
241
+ * consumer this provider has — a nightly archive written by a single hub
242
+ * singleton — that is the right trade: one transfer instead of N round trips,
243
+ * and the transfer either completes or does not.
244
+ *
245
+ * ── Verify-on-finalize ─────────────────────────────────────────────────────
246
+ * `finalizeUpload` stats the landed file and compares its size to the bytes
247
+ * the session was given. `close()` returning is not a durability statement
248
+ * over SMB — write-behind caching, oplocks and leases all sit between the
249
+ * client and the server's platters — and the backup manifest carries no
250
+ * checksum, so "the archive is there" is currently believed on the strength of
251
+ * a call that returned. One round trip per nightly archive turns that into
252
+ * "the server says the file is that many bytes", which is a materially
253
+ * stronger claim and is the most this can assert without a `backup` cap
254
+ * change.
255
+ *
256
+ * ── What every method does first ───────────────────────────────────────────
257
+ * Checks that `smbclient` exists. A provider whose binary is missing must
258
+ * REFUSE, loudly, and never resemble a provider whose share happens to be
259
+ * empty: `empty` is what D293's delete guard needs to see before it will let
260
+ * an operator destroy a location, and a full share behind a missing binary
261
+ * must not clear that bar.
262
+ */
263
+ var PROVIDER_ID = "smb-storage";
264
+ var DISPLAY_NAME = "SMB / CIFS";
265
+ /** Per-operation ceiling handed to smbclient (`-t`), in seconds. */
266
+ var SMB_TIMEOUT_SECONDS = 60;
267
+ function requireString(value, field, locationId) {
268
+ if (typeof value !== "string" || value.length === 0) throw new Error(`smb-storage: location "${locationId}" missing config.${field}`);
269
+ return value;
270
+ }
271
+ function optionalString(value) {
272
+ return typeof value === "string" && value.length > 0 ? value : void 0;
273
+ }
274
+ function optionalNumber(value) {
275
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
276
+ }
277
+ /** Read a `SmbConfig` out of a config record. Names the FIELD, never the value. */
278
+ function smbConfigFromRecord(config, locationId) {
279
+ const host = requireString(config["host"], "host", locationId);
280
+ const share = requireString(config["share"], "share", locationId);
281
+ if (host.includes("/") || host.includes("\\")) throw new Error(`smb-storage: config.host must be a bare hostname, without slashes`);
282
+ if (share.includes("/") || share.includes("\\")) throw new Error(`smb-storage: config.share must be a bare share name, without slashes`);
283
+ return {
284
+ host,
285
+ share,
286
+ basePath: normalizedBasePathPreservingCase(optionalString(config["basePath"]) ?? ""),
287
+ ...optionalNumber(config["port"]) !== void 0 ? { port: optionalNumber(config["port"]) } : {},
288
+ ...optionalString(config["username"]) !== void 0 ? { username: optionalString(config["username"]) } : {},
289
+ ...optionalString(config["password"]) !== void 0 ? { password: optionalString(config["password"]) } : {},
290
+ ...optionalString(config["domain"]) !== void 0 ? { domain: optionalString(config["domain"]) } : {},
291
+ ...optionalString(config["maxProtocol"]) !== void 0 ? { maxProtocol: optionalString(config["maxProtocol"]) } : {}
292
+ };
293
+ }
294
+ /** Trim slashes without lower-casing — the value is USED, not compared. */
295
+ function normalizedBasePathPreservingCase(basePath) {
296
+ return basePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
297
+ }
298
+ /**
299
+ * Map a raw smbclient failure onto a closed set of operator-facing reasons.
300
+ *
301
+ * The raw text is never forwarded. `samba-client` puts `--password` on the
302
+ * argv, and although `maskCmd` replaces the message with captured output, that
303
+ * output still names accounts and domains on an NTLM failure — and
304
+ * `storage.testConfig` returns this string verbatim to the caller.
305
+ */
306
+ function mapSmbError(err) {
307
+ const text = (err instanceof Error ? err.message : String(err)).toUpperCase();
308
+ if (text.includes("LOGON_FAILURE") || text.includes("ACCESS_DENIED")) return "authentication failed";
309
+ if (text.includes("BAD_NETWORK_NAME") || text.includes("NO_SUCH_SHARE")) return "share not found";
310
+ if (text.includes("OBJECT_NAME_NOT_FOUND") || text.includes("OBJECT_PATH_NOT_FOUND")) return "path not found on the share";
311
+ if (text.includes("CONNECTION_REFUSED") || text.includes("ECONNREFUSED")) return "connection refused";
312
+ if (text.includes("TIMED OUT") || text.includes("ETIMEDOUT") || text.includes("IO_TIMEOUT")) return "timed out";
313
+ if (text.includes("HOST_UNREACHABLE") || text.includes("ENOTFOUND") || text.includes("EHOSTUNREACH")) return "server unreachable";
314
+ if (text.includes("DISK_FULL") || text.includes("NO_SPACE")) return "the share is full";
315
+ return "the SMB operation failed — see the addon log for the server response";
316
+ }
317
+ var SmbStorageProvider = class {
318
+ logger;
319
+ static providerId = PROVIDER_ID;
320
+ static displayName = DISPLAY_NAME;
321
+ uploads = /* @__PURE__ */ new Map();
322
+ downloads = /* @__PURE__ */ new Map();
323
+ createClient;
324
+ hasBinary;
325
+ spoolRoot;
326
+ /**
327
+ * Cached binary probe. Refreshed only from `false` → re-probe, so a node
328
+ * that gains the binary (the runner's install landing late, an operator
329
+ * fixing it by hand) recovers without a restart, while the healthy path
330
+ * costs one probe for the life of the process.
331
+ */
332
+ binaryPresent = null;
333
+ constructor(logger, deps = {}) {
334
+ this.logger = logger;
335
+ this.createClient = deps.createClient ?? defaultSmbClientFactory;
336
+ this.hasBinary = deps.hasBinary ?? probeSmbBinary;
337
+ this.spoolRoot = deps.spoolDir ?? os.tmpdir();
338
+ }
339
+ async getProviderInfo() {
340
+ return {
341
+ providerId: PROVIDER_ID,
342
+ displayName: DISPLAY_NAME,
343
+ configSchema: SMB_CONFIG_SCHEMA,
344
+ nodeLocal: false,
345
+ shouldSaveDiskSpace: false,
346
+ minFreePercent: null
347
+ };
348
+ }
349
+ /**
350
+ * Validate config + probe connectivity.
351
+ *
352
+ * Three rules, all of them about what this string may contain: it goes
353
+ * straight back on the wire through `storage.testConfig`.
354
+ * 1. Validation errors name the FIELD, never the value — and a username is
355
+ * half a credential, so it is not interpolated either.
356
+ * 2. Library errors are MAPPED, never forwarded. The raw text is logged.
357
+ * 3. The config object is never logged, at any level.
358
+ */
359
+ async testLocation({ config }) {
360
+ if (!await this.binaryAvailable()) return {
361
+ ok: false,
362
+ error: new SmbBinaryUnavailableError().message
363
+ };
364
+ let cfg;
365
+ try {
366
+ cfg = smbConfigFromRecord(config, "(new)");
367
+ } catch (err) {
368
+ return {
369
+ ok: false,
370
+ error: err instanceof Error ? err.message : String(err)
371
+ };
372
+ }
373
+ try {
374
+ await (await this.clientFor(cfg)).list("");
375
+ return { ok: true };
376
+ } catch (err) {
377
+ this.logger.warn("smb-storage: testLocation failed", { meta: {
378
+ host: cfg.host,
379
+ share: cfg.share,
380
+ error: err instanceof Error ? err.message : String(err)
381
+ } });
382
+ return {
383
+ ok: false,
384
+ error: mapSmbError(err)
385
+ };
386
+ }
387
+ }
388
+ /**
389
+ * Would this candidate config land on a share an existing location already
390
+ * owns? Two records on one directory each run their own retention sweep
391
+ * against a manifest the other also rewrites, so the second one deletes
392
+ * archives the first still lists — and the failure surfaces at RESTORE time,
393
+ * on a file the manifest swears is there.
394
+ *
395
+ * The identity of "the same share" is provider knowledge:
396
+ * `(host, share, basePath)` compared case-insensitively, which no generic
397
+ * uniqueness rule over an opaque `config` record could know.
398
+ *
399
+ * **NOT WIRED YET — say so rather than let the docblock imply otherwise.**
400
+ * Nothing calls this in production. The two surfaces that could are
401
+ * `storage.testConfig` (which passes only `{ providerId, config }`, so it
402
+ * cannot see siblings) and the orchestrator's `upsertLocation` (which can,
403
+ * but reaching a provider-specific method from generic dispatch means either
404
+ * a cast or a new method on the `storage-provider` cap — a cap change, and
405
+ * therefore codegen and a framework release).
406
+ *
407
+ * It ships as a tested predicate because the hazard is real and the identity
408
+ * rule is the part that is easy to get wrong; the plumbing is the cheap part
409
+ * and belongs with the cap change that carries it. Until then, two locations
410
+ * on one share are possible and nothing refuses them.
411
+ */
412
+ findDuplicateShare(candidate, candidateId, existing) {
413
+ let candidateIdentity;
414
+ try {
415
+ candidateIdentity = shareIdentity(smbConfigFromRecord(candidate, candidateId));
416
+ } catch {
417
+ return null;
418
+ }
419
+ for (const other of existing) {
420
+ if (other.id === candidateId) continue;
421
+ if (other.providerId !== PROVIDER_ID) continue;
422
+ try {
423
+ if (shareIdentity(smbConfigFromRecord(other.config, other.id)) === candidateIdentity) return other;
424
+ } catch {
425
+ continue;
426
+ }
427
+ }
428
+ return null;
429
+ }
430
+ /**
431
+ * REFUSES, always.
432
+ *
433
+ * `resolve` exists on the cap to answer "give me a path I can open with
434
+ * node:fs", and there is no such path for a share this provider reaches over
435
+ * the wire. Returning `//nas/backups/camstack` — the shape SFTP, S3 and
436
+ * WebDAV return — is what makes the trap possible in the first place: the
437
+ * caller `fs.readdir`s it and gets either nothing or an unrelated local
438
+ * directory of the same name.
439
+ *
440
+ * The orchestrator already refuses this call before it arrives (D296). This
441
+ * is the second lock, and it is the one that holds if the orchestrator is
442
+ * ever bypassed — the provider itself never hands out a path that is not
443
+ * one.
444
+ */
445
+ async resolve({ location, relativePath }) {
446
+ this.logger.warn("smb-storage: REFUSED resolve() — an SMB share has no local path", { meta: {
447
+ id: location.id,
448
+ relativePath
449
+ } });
450
+ throw new Error(`smb-storage: location "${location.id}" is a remote SMB share and has no path this node can open with node:fs. Use the storage cap's read/write or chunked upload/download instead of resolving a path.`);
451
+ }
452
+ async write({ location, relativePath, data }) {
453
+ const spool = await this.spoolFile();
454
+ try {
455
+ await fsp.writeFile(spool, data);
456
+ await this.putFile(location, relativePath, spool, data.byteLength);
457
+ } finally {
458
+ await this.discardSpool(spool);
459
+ }
460
+ }
461
+ async read({ location, relativePath }) {
462
+ const spool = await this.spoolFile();
463
+ try {
464
+ await this.fetchFile(location, relativePath, spool);
465
+ const buf = await fsp.readFile(spool);
466
+ const out = new Uint8Array(new ArrayBuffer(buf.byteLength));
467
+ out.set(buf);
468
+ return out;
469
+ } finally {
470
+ await this.discardSpool(spool);
471
+ }
472
+ }
473
+ async exists({ location, relativePath }) {
474
+ return await this.statRemote(location, relativePath) !== null;
475
+ }
476
+ async list({ location, prefix }) {
477
+ const client = await this.clientForLocation(location);
478
+ const dir = safeSmbRelativePath(prefix ?? "");
479
+ try {
480
+ return (await client.list(dir)).filter((e) => e.name !== "." && e.name !== "..").map((e) => dir === "" ? e.name : `${dir}/${e.name}`);
481
+ } catch (err) {
482
+ if (isNotFound(err)) return [];
483
+ this.logger.warn("smb-storage: list failed", { meta: {
484
+ id: location.id,
485
+ prefix: dir,
486
+ error: mapSmbError(err)
487
+ } });
488
+ throw err;
489
+ }
490
+ }
491
+ async delete({ location, relativePath }) {
492
+ const client = await this.clientForLocation(location);
493
+ const target = safeSmbRelativePath(relativePath);
494
+ try {
495
+ await client.deleteFile(target);
496
+ } catch (err) {
497
+ if (isNotFound(err)) {
498
+ this.logger.debug("smb-storage: delete of an already-absent file", { meta: {
499
+ id: location.id,
500
+ relativePath: target
501
+ } });
502
+ return;
503
+ }
504
+ throw err;
505
+ }
506
+ }
507
+ /** Always `null` — see `getProviderInfo`. */
508
+ async getAvailableSpace(_input) {
509
+ return null;
510
+ }
511
+ async beginUpload({ location, relativePath }) {
512
+ await this.assertBinary();
513
+ const target = safeSmbRelativePath(relativePath);
514
+ const uploadId = randomUUID();
515
+ const spoolPath = await this.spoolFile();
516
+ await fsp.writeFile(spoolPath, new Uint8Array(0));
517
+ const session = {
518
+ location,
519
+ relativePath: target,
520
+ spoolPath,
521
+ bytesWritten: 0,
522
+ timer: scheduleIdleAbort(() => {
523
+ this.logger.warn("smb-storage: upload session idle — aborting", { meta: {
524
+ id: location.id,
525
+ relativePath: target,
526
+ uploadId
527
+ } });
528
+ this.abortUpload({ uploadId });
529
+ })
530
+ };
531
+ this.uploads.set(uploadId, session);
532
+ return { uploadId };
533
+ }
534
+ async writeChunk({ uploadId, offset, data }) {
535
+ const session = this.uploads.get(uploadId);
536
+ if (!session) throw new Error(`smb-storage: unknown uploadId "${uploadId}"`);
537
+ if (offset !== session.bytesWritten) throw new Error(`smb-storage: out-of-order chunk for upload "${uploadId}" — expected offset ${session.bytesWritten}, got ${offset}. This provider spools sequentially.`);
538
+ const handle = await fsp.open(session.spoolPath, "a");
539
+ try {
540
+ await handle.write(data);
541
+ } finally {
542
+ await handle.close();
543
+ }
544
+ session.bytesWritten += data.byteLength;
545
+ session.timer = rearmIdleAbort(session.timer, () => {
546
+ this.logger.warn("smb-storage: upload session idle — aborting", { meta: {
547
+ id: session.location.id,
548
+ relativePath: session.relativePath,
549
+ uploadId
550
+ } });
551
+ this.abortUpload({ uploadId });
552
+ });
553
+ }
554
+ /**
555
+ * Transfer the spooled file, then VERIFY it landed at the right size.
556
+ *
557
+ * The sequence is `put` to `<name>.partial`, delete any existing target,
558
+ * rename onto it, stat. Rename-over-existing is not universally permitted on
559
+ * SMB, hence the delete — which means the finalize is NOT atomic: a crash
560
+ * between the delete and the rename destroys the previous file without
561
+ * landing the new one. For the one consumer this has (`backups`, whose
562
+ * archive names are unique per run) the target never exists, so that window
563
+ * is not entered in practice. It is written down because it WOULD be entered
564
+ * by any consumer that overwrites a stable filename.
565
+ */
566
+ async finalizeUpload({ uploadId }) {
567
+ const session = this.uploads.get(uploadId);
568
+ if (!session) throw new Error(`smb-storage: unknown uploadId "${uploadId}"`);
569
+ clearTimeout(session.timer);
570
+ this.uploads.delete(uploadId);
571
+ try {
572
+ await this.putFile(session.location, session.relativePath, session.spoolPath, session.bytesWritten);
573
+ } finally {
574
+ await this.discardSpool(session.spoolPath);
575
+ }
576
+ }
577
+ async abortUpload({ uploadId }) {
578
+ const session = this.uploads.get(uploadId);
579
+ if (!session) return;
580
+ clearTimeout(session.timer);
581
+ this.uploads.delete(uploadId);
582
+ this.logger.info("smb-storage: upload aborted — nothing was written to the share", { meta: {
583
+ id: session.location.id,
584
+ relativePath: session.relativePath,
585
+ bytesSpooled: session.bytesWritten
586
+ } });
587
+ await this.discardSpool(session.spoolPath);
588
+ }
589
+ async beginDownload({ location, relativePath }) {
590
+ const spoolPath = await this.spoolFile();
591
+ let sizeBytes;
592
+ try {
593
+ await this.fetchFile(location, relativePath, spoolPath);
594
+ sizeBytes = (await fsp.stat(spoolPath)).size;
595
+ } catch (err) {
596
+ await this.discardSpool(spoolPath);
597
+ throw err;
598
+ }
599
+ const downloadId = randomUUID();
600
+ this.downloads.set(downloadId, {
601
+ spoolPath,
602
+ sizeBytes,
603
+ timer: scheduleIdleAbort(() => {
604
+ this.logger.warn("smb-storage: download session idle — closing", { meta: {
605
+ id: location.id,
606
+ relativePath,
607
+ downloadId
608
+ } });
609
+ this.endDownload({ downloadId });
610
+ })
611
+ });
612
+ return {
613
+ downloadId,
614
+ sizeBytes
615
+ };
616
+ }
617
+ async readChunk({ downloadId, offset, length }) {
618
+ const session = this.downloads.get(downloadId);
619
+ if (!session) throw new Error(`smb-storage: unknown downloadId "${downloadId}"`);
620
+ const handle = await fsp.open(session.spoolPath, "r");
621
+ try {
622
+ const out = new Uint8Array(new ArrayBuffer(Math.max(0, length)));
623
+ const { bytesRead } = await handle.read(out, 0, out.byteLength, offset);
624
+ session.timer = rearmIdleAbort(session.timer, () => {
625
+ this.endDownload({ downloadId });
626
+ });
627
+ return bytesRead === out.byteLength ? out : out.slice(0, bytesRead);
628
+ } finally {
629
+ await handle.close();
630
+ }
631
+ }
632
+ async endDownload({ downloadId }) {
633
+ const session = this.downloads.get(downloadId);
634
+ if (!session) return;
635
+ clearTimeout(session.timer);
636
+ this.downloads.delete(downloadId);
637
+ await this.discardSpool(session.spoolPath);
638
+ }
639
+ /** Drop every open session. Called from the addon's `onShutdown`. */
640
+ async dispose() {
641
+ for (const uploadId of [...this.uploads.keys()]) await this.abortUpload({ uploadId });
642
+ for (const downloadId of [...this.downloads.keys()]) await this.endDownload({ downloadId });
643
+ }
644
+ async binaryAvailable() {
645
+ if (this.binaryPresent === true) return true;
646
+ this.binaryPresent = await this.hasBinary();
647
+ return this.binaryPresent;
648
+ }
649
+ async assertBinary() {
650
+ if (await this.binaryAvailable()) return;
651
+ throw new SmbBinaryUnavailableError();
652
+ }
653
+ async clientForLocation(location) {
654
+ await this.assertBinary();
655
+ return this.clientFor(smbConfigFromRecord(location.config, location.id));
656
+ }
657
+ /**
658
+ * A client scoped to `(host, share, basePath)`.
659
+ *
660
+ * Deliberately NOT pooled. `samba-client` holds no connection between calls
661
+ * — every method spawns a fresh `smbclient` — so a pool would cache an
662
+ * options object and nothing else, while giving the false impression that a
663
+ * session survives. The cost of constructing one is an object literal.
664
+ */
665
+ async clientFor(cfg) {
666
+ return this.createClient({
667
+ address: smbAddress(cfg.host, cfg.share),
668
+ timeout: SMB_TIMEOUT_SECONDS,
669
+ maskCmd: true,
670
+ ...cfg.username !== void 0 ? { username: cfg.username } : {},
671
+ ...cfg.password !== void 0 ? { password: cfg.password } : {},
672
+ ...cfg.domain !== void 0 ? { domain: cfg.domain } : {},
673
+ ...cfg.port !== void 0 ? { port: cfg.port } : {},
674
+ ...cfg.maxProtocol !== void 0 ? { maxProtocol: cfg.maxProtocol } : {},
675
+ ...cfg.basePath !== "" ? { directory: cfg.basePath } : {}
676
+ });
677
+ }
678
+ /** One entry of a remote listing, or `null` when the path does not exist. */
679
+ async statRemote(location, relativePath) {
680
+ const client = await this.clientForLocation(location);
681
+ const target = safeSmbRelativePath(relativePath);
682
+ const dir = target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : "";
683
+ const name = target.includes("/") ? target.slice(target.lastIndexOf("/") + 1) : target;
684
+ try {
685
+ return (await client.list(dir)).find((e) => e.name.toLowerCase() === name.toLowerCase()) ?? null;
686
+ } catch (err) {
687
+ if (isNotFound(err)) return null;
688
+ throw err;
689
+ }
690
+ }
691
+ /** `put` the local file, then rename onto the target and verify its size. */
692
+ async putFile(location, relativePath, localPath, expectedBytes) {
693
+ const client = await this.clientForLocation(location);
694
+ const target = safeSmbRelativePath(relativePath);
695
+ const partial = `${target}.partial`;
696
+ await this.ensureRemoteDirs(client, target);
697
+ await client.sendFile(localPath, partial);
698
+ try {
699
+ await client.deleteFile(target);
700
+ } catch (err) {
701
+ if (!isNotFound(err)) throw err;
702
+ }
703
+ await client.execute("rename", [partial, target], "");
704
+ const landed = await this.statRemote(location, target);
705
+ if (landed === null) throw new Error(`smb-storage: "${target}" is not present on the share after the transfer completed`);
706
+ if (landed.size !== expectedBytes) {
707
+ this.logger.error("smb-storage: size mismatch after upload — the file did NOT land intact", { meta: {
708
+ id: location.id,
709
+ relativePath: target,
710
+ expectedBytes,
711
+ landedBytes: landed.size
712
+ } });
713
+ throw new Error(`smb-storage: "${target}" landed at ${landed.size} bytes but ${expectedBytes} were sent. Treating the transfer as failed — a returned write is not a durability statement over SMB.`);
714
+ }
715
+ }
716
+ async fetchFile(location, relativePath, localPath) {
717
+ const client = await this.clientForLocation(location);
718
+ const target = safeSmbRelativePath(relativePath);
719
+ await client.getFile(target, localPath);
720
+ }
721
+ /**
722
+ * `mkdir -p` for the parent chain of a target path. smbclient's `mkdir` is
723
+ * single-level and errors on an existing directory, so this walks the chain
724
+ * and treats every failure as "it is already there" — the subsequent `put`
725
+ * is what actually decides whether the directory exists.
726
+ */
727
+ async ensureRemoteDirs(client, target) {
728
+ const segments = pathSegments(target);
729
+ if (segments.length <= 1) return;
730
+ let prefix = "";
731
+ for (const segment of segments.slice(0, -1)) {
732
+ prefix = prefix === "" ? segment : `${prefix}/${segment}`;
733
+ try {
734
+ await client.mkdir(prefix);
735
+ } catch {}
736
+ }
737
+ }
738
+ async spoolFile() {
739
+ const dir = await fsp.mkdtemp(path.join(this.spoolRoot, "camstack-smb-"));
740
+ return path.join(dir, "spool.bin");
741
+ }
742
+ async discardSpool(spoolPath) {
743
+ try {
744
+ await fsp.rm(path.dirname(spoolPath), {
745
+ recursive: true,
746
+ force: true
747
+ });
748
+ } catch (err) {
749
+ this.logger.warn("smb-storage: could not remove a spool directory", { meta: {
750
+ spoolPath,
751
+ error: err instanceof Error ? err.message : String(err)
752
+ } });
753
+ }
754
+ }
755
+ };
756
+ /** smbclient's two "it is not there" statuses. */
757
+ function isNotFound(err) {
758
+ const text = (err instanceof Error ? err.message : String(err)).toUpperCase();
759
+ return text.includes("NT_STATUS_OBJECT_NAME_NOT_FOUND") || text.includes("NT_STATUS_NO_SUCH_FILE") || text.includes("NT_STATUS_OBJECT_PATH_NOT_FOUND");
760
+ }
761
+ //#endregion
762
+ //#region src/smb.addon.ts
763
+ /**
764
+ * SMB / CIFS storage-provider addon — the fourth manifest entry in
765
+ * `@camstack/addon-remote-storage`.
766
+ *
767
+ * Each cap-collection registrant needs a distinct addonId: `CapabilityRegistry`
768
+ * keys collection providers by `Map<addonId, …>`, so registering a second
769
+ * provider from `sftp-storage` would silently replace SFTP rather than join it.
770
+ *
771
+ * The binary this provider wraps (`smbclient`) is declared in the package
772
+ * manifest under `camstack.addons[].systemDependencies` and installed by the
773
+ * runner BEFORE this class initialises. When that install fails the addon
774
+ * still loads — the provider refuses every call with
775
+ * `SmbBinaryUnavailableError`, so its locations read as unreachable rather
776
+ * than as empty. Empty is deletable (D293); unreachable is not.
777
+ */
778
+ var SmbStorageAddon = class extends BaseAddon {
779
+ impl = null;
780
+ constructor() {
781
+ super({});
782
+ }
783
+ async onInitialize() {
784
+ this.impl = new SmbStorageProvider(this.ctx.logger);
785
+ return [{
786
+ capability: storageProviderCapability,
787
+ provider: this.impl
788
+ }];
789
+ }
790
+ async onShutdown() {
791
+ await this.impl?.dispose();
792
+ this.impl = null;
793
+ }
794
+ };
795
+ //#endregion
796
+ export { SmbStorageAddon, SmbStorageAddon as default, SmbBinaryUnavailableError as n, SmbStorageProvider as t };