@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.
- package/dist/index.js +4 -0
- package/dist/index.mjs +2 -1
- package/dist/s3.addon.js +1 -1
- package/dist/s3.addon.mjs +1 -1
- package/dist/sftp.addon.js +5 -2
- package/dist/sftp.addon.mjs +5 -2
- package/dist/{shared-DSJ7V_r6.js → shared-BHqbklNT.js} +387 -19
- package/dist/{shared-8LifrPLV.mjs → shared-CSwUP1hZ.mjs} +387 -19
- package/dist/smb.addon.js +806 -0
- package/dist/smb.addon.mjs +796 -0
- package/dist/webdav.addon.js +1 -1
- package/dist/webdav.addon.mjs +1 -1
- package/package.json +40 -6
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import { c as BaseAddon, i as rearmIdleAbort, o as scheduleIdleAbort, s as storageProviderCapability } from "./shared-CSwUP1hZ.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 };
|