@camstack/addon-remote-storage 1.2.42 → 1.2.45

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