saltcorn-samba 0.4.11 → 0.4.12

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/CHANGELOG.md CHANGED
@@ -4,6 +4,62 @@ All notable changes to `saltcorn-samba` are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.4.12] – 2026-07-06
8
+
9
+ ### Fixed – **`0xC0000033` beim readdir gegen Samba 4.23 endgültig behoben (Root Cause identifiziert)**
10
+
11
+ Nach ausführlicher Wire-Level-Analyse (siehe `smb-diag-report.txt`
12
+ aus 0.4.11) ist die eigentliche Ursache identifiziert:
13
+
14
+ **Bug in `smb3-client@0.2.0`, Datei `dist/wire/structs/queryDirectory.js`:**
15
+ Der Encoder für SMB2 QUERY_DIRECTORY setzt das `FileNameOffset`-Feld
16
+ immer hart auf 96, auch wenn `FileNameLength = 0` gesendet wird
17
+ (z. B. auf der 2. und folgenden Enumeration-Seite).
18
+ [MS-SMB2 §2.2.33](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/10906442-294c-46d3-8515-c277efe1f752)
19
+ verlangt für diesen Fall **`FileNameOffset = 0`**.
20
+ Windows Server toleriert die Fehlbelegung, Samba 4.23 lehnt sie
21
+ strikt mit `STATUS_OBJECT_NAME_INVALID` (`0xC0000033`) ab —
22
+ deshalb funktioniert nichts, was das Listing über mehrere Pages
23
+ braucht, wovon `smb3-client` grundsätzlich ausgeht.
24
+
25
+ ### Fix
26
+
27
+ Die frühere `readdir-compat.js` (die 3 FileInformationClass-Werte
28
+ durchprobierte) ist ersetzt durch einen echten **Wire-Format-Patch**:
29
+
30
+ - Eigener spec-konformer `encodeQueryDirectoryRequest`-Encoder in
31
+ `readdir-compat.js`
32
+ - Eigene QUERY_DIRECTORY-Loop, die den gepatchten Encoder verwendet
33
+ - Wiederverwendung von `Open.withOpen` aus `smb3-client` (Open, Close,
34
+ Tree-Connect bleiben unverändert) via dynamischem ESM-Import über
35
+ `file://` URLs (das `exports`-Gate von `smb3-client` sperrt sonst
36
+ jeden Subpath-Import)
37
+ - `smb-client.js` nutzt jetzt ausschließlich `readdirCompat` als
38
+ readdir-Pfad. Kein Fallback mehr auf das kaputte `client.readdir()`.
39
+ - Bonus: Die Rich-Dirents aus dem gepatchten QUERY_DIRECTORY liefern
40
+ Name, Größe, mtime und ctime in einem Roundtrip. Der bisherige
41
+ Fan-out mit einem `stat()`-Aufruf pro Eintrag entfällt — große
42
+ Verzeichnisse werden dadurch **deutlich schneller**.
43
+
44
+ ### Bekannt – Upstream-Fix eingereicht
45
+
46
+ Parallel wurde ein Bug-Report an `smb3-client` (GitHub:
47
+ `euricojardim/smb3-client`) vorbereitet inklusive Reproduktions-
48
+ Diagnose und Patch-Vorschlag. Sobald der Upstream-Fix in einer
49
+ neuen `smb3-client`-Version verfügbar ist, kann dieses Plugin die
50
+ `readdir-compat.js` wieder entfernen und die eingebaute API direkt
51
+ nutzen.
52
+
53
+ ### Migration – keine Konfigurationsänderung nötig
54
+
55
+ Der Fix greift automatisch. Der gelbe Hinweis „Basispfad bestätigt,
56
+ aber Auflisten funktioniert nicht“ aus 0.4.9–0.4.11 fällt weg, weil
57
+ das Auflisten jetzt tatsächlich funktioniert. Der File-Manager
58
+ zeigt Ordner-Inhalte, die Baum-Ansicht rendert Verzeichnis-Bäume,
59
+ PDF-Ansicht und Datei-Downloads funktionieren unverändert.
60
+
61
+ ---
62
+
7
63
  ## [0.4.11] – 2026-07-06
8
64
 
9
65
  ### Changed – **Ehrliche Fehlerpropagation statt stiller Fallback**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saltcorn-samba",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "Saltcorn plugin: browse, upload, rename and delete files on a Samba/CIFS share via SMB 3.1.1 (AES-CMAC signing, optional encryption). File-manager view, directory tree, inline PDF viewer, external-app open (smb://).",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/readdir-compat.js CHANGED
@@ -1,471 +1,290 @@
1
+ "use strict";
2
+
1
3
  /**
2
- * Compatibility layer for smb3-client's readdir on Samba 4.23+.
4
+ * readdir-compat.js — saltcorn-samba 0.4.12
5
+ *
6
+ * Behebt einen QUERY_DIRECTORY-Wire-Format-Bug in smb3-client@0.2.0 der
7
+ * gegen Samba 4.23 zu STATUS_OBJECT_NAME_INVALID (0xC0000033) führt.
3
8
  *
4
- * Background
5
- * ----------
6
- * `smb3-client@0.2.0` hard-codes `FileInformationClass = 37`
7
- * (`FileIdBothDirectoryInformation`) in its `QUERY_DIRECTORY` request.
8
- * Some Samba 4.23+ configurations reject that class with
9
- * `STATUS_OBJECT_NAME_INVALID` (0xC0000033) for specific directories
10
- * even though the directory can be opened, stat'd and traversed. The
11
- * same directories can be listed by `smbclient` (which requests class 3
12
- * or 1) and by Windows Explorer.
9
+ * Root cause (dokumentiert in tools/diag-basepath.js-Report):
10
+ * smb3-client's `encodeQueryDirectoryRequest` setzt `FileNameOffset`
11
+ * immer auf 96 — auch wenn `FileNameLength === 0` gesendet wird
12
+ * (auf 2. und folgenden Enumeration-Pages).
13
+ * MS-SMB2 §2.2.33 verlangt für diesen Fall FileNameOffset = 0.
14
+ * Windows toleriert die Fehlbelegung, Samba 4.23 nicht.
13
15
  *
14
- * This module implements `readdirCompat(client, sharePath)` which:
16
+ * Strategie:
17
+ * Statt smb3-client's `client.readdir()` / `readdirAll` verwenden wir
18
+ * einen eigenen Loop, der `Open.withOpen` von smb3-client wieder-
19
+ * verwendet (Open, Close, Tree-Connect bleiben unverändert) und nur
20
+ * die kaputte QUERY_DIRECTORY-Loop durch eine spec-konforme ersetzt.
15
21
  *
16
- * 1. Reaches into smb3-client's *internal* wire primitives (via direct
17
- * filesystem imports see NOTE below).
18
- * 2. Opens the directory the same way `client.readdir()` does.
19
- * 3. Sends `QUERY_DIRECTORY` with `FileBothDirectoryInformation (3)`,
20
- * which is the identical layout minus the 8-byte trailing `FileId`
21
- * field.
22
- * 4. Falls back to `FileDirectoryInformation (1)` on the same error.
23
- * 5. Remembers the working class on the given client so subsequent
24
- * readdir calls do not need to retry.
22
+ * Nur die interne wire-Struktur wird via file:// URL importiert, weil
23
+ * smb3-client's `exports`-Gate keine Subpath-Imports zulässt.
25
24
  *
26
- * NOTE subpath imports
27
- * ----------------------
28
- * `smb3-client` publishes with `"exports": { ".": ... }`, so subpath
29
- * imports via package name (`smb3-client/dist/...`) are blocked by
30
- * Node. We work around that by resolving the absolute filesystem path
31
- * of the main entry (via a dynamic `import("smb3-client")` and reading
32
- * `import.meta.url` off the returned URL of a re-export) and then
33
- * constructing `file://` URLs to the sibling internal modules. This
34
- * pattern is stable across `smb3-client 0.2.x` — the internal file
35
- * tree is documented in its README.
25
+ * Idempotent, thread-safe (Node ist single-threaded, aber wir wollen
26
+ * keinen Race auf paralleler Erst-Nutzung).
36
27
  */
37
28
 
38
- "use strict";
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+ const url = require("url");
39
32
 
40
- // Lazy, cached bundle of the internal modules we need. Loaded on first use.
41
- let _internalsPromise = null;
33
+ let LOAD_PROMISE = null;
34
+ let INTERNALS = null;
42
35
 
43
- async function loadInternals() {
44
- if (_internalsPromise) return _internalsPromise;
45
- _internalsPromise = (async () => {
46
- // Locate smb3-client's package root on disk. We do this by walking
47
- // require.resolve.paths() and looking for a real dist/index.js.
48
- // (require.resolve() itself is blocked by the "exports" gate.)
49
- const path = require("path");
50
- const fs = require("fs");
51
- const { pathToFileURL } = require("url");
36
+ /**
37
+ * Interne Wire-/Path-Module aus smb3-client laden. Lazy + gecached.
38
+ * Wirft, wenn smb3-client nicht auffindbar oder Interna verändert.
39
+ */
40
+ function loadInternals() {
41
+ if (INTERNALS) return Promise.resolve(INTERNALS);
42
+ if (LOAD_PROMISE) return LOAD_PROMISE;
52
43
 
53
- let pkgRoot = null;
54
- const candidatePaths = require.resolve.paths("smb3-client") || [];
55
- for (const p of candidatePaths) {
56
- const guess = path.join(p, "smb3-client");
57
- if (fs.existsSync(path.join(guess, "dist", "index.js"))) {
58
- pkgRoot = guess;
44
+ LOAD_PROMISE = (async () => {
45
+ // smb3-client-Root finden (package.json ist via exports-Gate blockiert,
46
+ // wir gehen über den Ordner-Pfad)
47
+ const candidates = [
48
+ path.join(__dirname, "node_modules", "smb3-client"),
49
+ path.join(__dirname, "..", "node_modules", "smb3-client"),
50
+ path.join(__dirname, "..", "..", "node_modules", "smb3-client"),
51
+ path.join(__dirname, "..", "..", "..", "node_modules", "smb3-client"),
52
+ ];
53
+ let smb3Root = null;
54
+ for (const c of candidates) {
55
+ try {
56
+ fs.accessSync(path.join(c, "dist", "index.js"));
57
+ smb3Root = c;
59
58
  break;
60
- }
59
+ } catch (_) {}
61
60
  }
62
- if (!pkgRoot) {
61
+ if (!smb3Root) {
63
62
  throw new Error(
64
- "readdir-compat: cannot locate smb3-client package root on disk"
63
+ "readdir-compat: smb3-client-Installation nicht gefunden. " +
64
+ "Erwartet in node_modules/smb3-client relativ zu " +
65
+ __dirname
65
66
  );
66
67
  }
67
- const dist = path.join(pkgRoot, "dist");
68
- const urlOf = (rel) =>
69
- pathToFileURL(path.join(dist, rel)).href;
68
+
69
+ const distDir = path.join(smb3Root, "dist");
70
+ const load = (rel) =>
71
+ import(url.pathToFileURL(path.join(distDir, rel)).href);
70
72
 
71
73
  const [
72
- queryDirectoryMod,
73
- createMod,
74
- closeMod,
75
- commandsMod,
74
+ bufMod,
75
+ qdMod,
76
+ cmdMod,
77
+ qiMod,
78
+ createStructMod,
76
79
  pathsMod,
77
- bufferMod,
80
+ errMod,
81
+ openMod,
78
82
  ] = await Promise.all([
79
- import(urlOf("wire/structs/queryDirectory.js")),
80
- import(urlOf("wire/structs/create.js")),
81
- import(urlOf("wire/structs/close.js")),
82
- import(urlOf("wire/commands.js")),
83
- import(urlOf("paths.js")),
84
- import(urlOf("wire/buffer.js")),
83
+ load("wire/buffer.js"),
84
+ load("wire/structs/queryDirectory.js"),
85
+ load("wire/commands.js"),
86
+ load("wire/structs/queryInfo.js"),
87
+ load("wire/structs/create.js"),
88
+ load("paths.js"),
89
+ load("errors.js"),
90
+ load("open/open.js"),
85
91
  ]);
86
- return {
87
- encodeQueryDirectoryRequest: queryDirectoryMod.encodeQueryDirectoryRequest,
88
- decodeQueryDirectoryResponse: queryDirectoryMod.decodeQueryDirectoryResponse,
89
- QueryDirectoryFlag: queryDirectoryMod.QueryDirectoryFlag,
90
- encodeCreateRequest: createMod.encodeCreateRequest,
91
- decodeCreateResponse: createMod.decodeCreateResponse,
92
- encodeCloseRequest: closeMod.encodeCloseRequest,
93
- CreateDisposition: createMod.CreateDisposition,
94
- FileAccess: createMod.FileAccess,
95
- FileAttribute: createMod.FileAttribute,
96
- ShareAccess: createMod.ShareAccess,
97
- SmbCommand: commandsMod.SmbCommand,
98
- NTStatus: commandsMod.NTStatus,
99
- isSuccess: commandsMod.isSuccess,
100
- statusName: commandsMod.statusName,
92
+
93
+ if (typeof bufMod.Writer !== "function")
94
+ throw new Error("readdir-compat: Writer-Klasse fehlt");
95
+ if (typeof qdMod.decodeQueryDirectoryResponse !== "function")
96
+ throw new Error("readdir-compat: decodeQueryDirectoryResponse fehlt");
97
+ if (typeof qdMod.parseFileIdBothDirectoryInformation !== "function")
98
+ throw new Error("readdir-compat: parseFileIdBothDirectoryInformation fehlt");
99
+ if (typeof openMod.Open !== "function")
100
+ throw new Error("readdir-compat: Open-Klasse fehlt");
101
+
102
+ INTERNALS = {
103
+ smb3Root,
104
+ Writer: bufMod.Writer,
105
+ decodeQueryDirectoryResponse: qdMod.decodeQueryDirectoryResponse,
106
+ parseFileIdBothDirectoryInformation:
107
+ qdMod.parseFileIdBothDirectoryInformation,
108
+ QueryDirectoryFlag: qdMod.QueryDirectoryFlag,
109
+ SmbCommand: cmdMod.SmbCommand,
110
+ NTStatus: cmdMod.NTStatus,
111
+ isSuccess: cmdMod.isSuccess,
112
+ statusName: cmdMod.statusName,
113
+ FileInformationClass: qiMod.FileInformationClass,
114
+ FileAccess: createStructMod.FileAccess,
115
+ ShareAccess: createStructMod.ShareAccess,
116
+ CreateDisposition: createStructMod.CreateDisposition,
117
+ CreateOptions: createStructMod.CreateOptions,
118
+ FileAttribute: createStructMod.FileAttribute,
101
119
  splitSharePath: pathsMod.splitSharePath,
102
120
  toSmbPath: pathsMod.toSmbPath,
103
- Reader: bufferMod.Reader,
121
+ smbTimeToDate: pathsMod.smbTimeToDate,
122
+ SmbError: errMod.SmbError,
123
+ Open: openMod.Open,
104
124
  };
125
+ return INTERNALS;
105
126
  })();
106
- return _internalsPromise;
107
- }
108
-
109
- // ---------------------------------------------------------------------------
110
- // Parsers for the alternative information classes
111
- // ---------------------------------------------------------------------------
112
- //
113
- // [MS-FSCC] 2.4.10 FileDirectoryInformation:
114
- // NextEntryOffset u32
115
- // FileIndex u32
116
- // CreationTime u64
117
- // LastAccessTime u64
118
- // LastWriteTime u64
119
- // ChangeTime u64
120
- // EndOfFile u64
121
- // AllocationSize u64
122
- // FileAttributes u32
123
- // FileNameLength u32
124
- // FileName wchar[]
125
- //
126
- // [MS-FSCC] 2.4.6 FileBothDirectoryInformation adds after FileNameLength:
127
- // EaSize u32
128
- // ShortNameLength u8
129
- // Reserved1 u8
130
- // ShortName wchar[12] (24 bytes)
131
- // FileName wchar[]
132
127
 
133
- function parseFileDirectoryInformation(buf, Reader) {
134
- const out = [];
135
- let off = 0;
136
- while (off < buf.length) {
137
- const r = new Reader(buf);
138
- r.offset = off;
139
- const next = r.u32();
140
- r.u32(); // FileIndex
141
- const creationTime = r.u64();
142
- const lastAccessTime = r.u64();
143
- const lastWriteTime = r.u64();
144
- const changeTime = r.u64();
145
- const endOfFile = r.u64();
146
- r.u64(); // AllocationSize
147
- const fileAttributes = r.u32();
148
- const fileNameLength = r.u32();
149
- const fileName = fileNameLength > 0 ? r.utf16(fileNameLength) : "";
150
- out.push({
151
- fileName,
152
- endOfFile,
153
- fileAttributes,
154
- creationTime,
155
- lastAccessTime,
156
- lastWriteTime,
157
- changeTime,
158
- });
159
- if (next === 0) break;
160
- off += next;
161
- }
162
- return out;
128
+ return LOAD_PROMISE;
163
129
  }
164
130
 
165
- function parseFileBothDirectoryInformation(buf, Reader) {
166
- const out = [];
167
- let off = 0;
168
- while (off < buf.length) {
169
- const r = new Reader(buf);
170
- r.offset = off;
171
- const next = r.u32();
172
- r.u32(); // FileIndex
173
- const creationTime = r.u64();
174
- const lastAccessTime = r.u64();
175
- const lastWriteTime = r.u64();
176
- const changeTime = r.u64();
177
- const endOfFile = r.u64();
178
- r.u64(); // AllocationSize
179
- const fileAttributes = r.u32();
180
- const fileNameLength = r.u32();
181
- r.u32(); // EaSize
182
- r.u8(); // ShortNameLength
183
- r.u8(); // Reserved1
184
- r.bytes(24); // ShortName
185
- const fileName = fileNameLength > 0 ? r.utf16(fileNameLength) : "";
186
- out.push({
187
- fileName,
188
- endOfFile,
189
- fileAttributes,
190
- creationTime,
191
- lastAccessTime,
192
- lastWriteTime,
193
- changeTime,
194
- });
195
- if (next === 0) break;
196
- off += next;
197
- }
198
- return out;
199
- }
200
-
201
- // FileIdBothDirectoryInformation (37) — same parser as smb3-client
202
- // (used only if smb3-client's own readdir failed for OTHER reasons).
203
- function parseFileIdBothDirectoryInformation(buf, Reader) {
204
- const out = [];
205
- let off = 0;
206
- while (off < buf.length) {
207
- const r = new Reader(buf);
208
- r.offset = off;
209
- const next = r.u32();
210
- r.u32(); // FileIndex
211
- const creationTime = r.u64();
212
- const lastAccessTime = r.u64();
213
- const lastWriteTime = r.u64();
214
- const changeTime = r.u64();
215
- const endOfFile = r.u64();
216
- r.u64(); // AllocationSize
217
- const fileAttributes = r.u32();
218
- const fileNameLength = r.u32();
219
- r.u32(); // EaSize
220
- r.u8(); // ShortNameLength
221
- r.u8(); // Reserved1
222
- r.bytes(24); // ShortName
223
- r.u16(); // Reserved2
224
- r.bytes(8); // FileId
225
- const fileName = fileNameLength > 0 ? r.utf16(fileNameLength) : "";
226
- out.push({
227
- fileName,
228
- endOfFile,
229
- fileAttributes,
230
- creationTime,
231
- lastAccessTime,
232
- lastWriteTime,
233
- changeTime,
234
- });
235
- if (next === 0) break;
236
- off += next;
237
- }
238
- return out;
239
- }
240
-
241
- // ---------------------------------------------------------------------------
242
- // Per-client sticky preferred information class
243
- // ---------------------------------------------------------------------------
244
-
245
- const PREFERRED_CLASS = new WeakMap(); // raw smb3-client Client → number
246
-
247
- // Ordered list of classes we try. First one is smb3-client's default, so
248
- // clients on servers that accept it get zero extra round-trips.
249
- const TRY_ORDER = [
250
- { code: 37, parse: parseFileIdBothDirectoryInformation, name: "FileIdBothDirectoryInformation" },
251
- { code: 3, parse: parseFileBothDirectoryInformation, name: "FileBothDirectoryInformation" },
252
- { code: 1, parse: parseFileDirectoryInformation, name: "FileDirectoryInformation" },
253
- ];
254
-
255
- // ---------------------------------------------------------------------------
256
- // Public entrypoint
257
- // ---------------------------------------------------------------------------
258
-
259
131
  /**
260
- * List a directory using the same open-and-query dance as
261
- * `client.readdir(path)` but with a configurable — and self-fallback —
262
- * `FileInformationClass`.
263
- *
264
- * @param {object} rawClient — instance of smb3-client's `Client`
265
- * @param {string} sharePath — "share/sub/dir" (share = first segment)
266
- * @returns {Promise<Array<{name, isDirectory, size, mtime, birthtime, ctime}>>}
132
+ * Spec-konformer Encoder für SMB2 QUERY_DIRECTORY (MS-SMB2 §2.2.33).
133
+ * Anders als das Original:
134
+ * - FileNameOffset = 0, wenn kein Suchpattern (statt 96)
135
+ * Ansonsten identisch (Struktur + Padding-Byte).
267
136
  */
268
- async function readdirCompat(rawClient, sharePath) {
269
- const int = await loadInternals();
270
- const {
271
- encodeQueryDirectoryRequest,
272
- decodeQueryDirectoryResponse,
273
- QueryDirectoryFlag,
274
- encodeCreateRequest,
275
- decodeCreateResponse,
276
- encodeCloseRequest,
277
- CreateDisposition,
278
- FileAccess,
279
- ShareAccess,
280
- SmbCommand,
281
- NTStatus,
282
- isSuccess,
283
- statusName,
284
- splitSharePath,
285
- toSmbPath,
286
- Reader,
287
- } = int;
288
-
289
- const { share, rest } = splitSharePath(sharePath);
290
- // treeFor is a "private" (TS-private, JS-public) method on Client.
291
- const tree = await rawClient.treeFor(share);
292
-
293
- // Open the target directory with DIRECTORY_FILE createOption (=1),
294
- // matching what smb3-client itself does for readdir.
295
- const createBody = encodeCreateRequest({
296
- filename: toSmbPath(rest),
297
- desiredAccess:
298
- FileAccess.FILE_READ_DATA | FileAccess.FILE_READ_ATTRIBUTES,
299
- shareAccess:
300
- ShareAccess.READ | ShareAccess.WRITE | ShareAccess.DELETE,
301
- createDisposition: CreateDisposition.OPEN,
302
- createOptions: 1, // DIRECTORY_FILE
303
- fileAttributes: 0,
304
- });
305
- const createSigning = tree.session.makeSigning();
306
- const createResp = await tree.conn.send(SmbCommand.CREATE, createBody, {
307
- sessionId: tree.session.sessionId,
308
- treeId: tree.treeId,
309
- ...(createSigning !== undefined ? { signing: createSigning } : {}),
310
- encrypt: tree.encryptRequired,
311
- creditCharge: 1,
312
- });
313
- if (!isSuccess(createResp.header.status)) {
314
- const e = new Error(
315
- "CREATE failed: " + statusName(createResp.header.status)
316
- );
317
- e.status = createResp.header.status;
318
- throw e;
319
- }
320
- const meta = decodeCreateResponse(createResp.body);
321
- const fileId = meta.fileId;
322
-
323
- try {
324
- // Try preferred class first, then the rest in TRY_ORDER.
325
- const preferred = PREFERRED_CLASS.get(rawClient);
326
- const order = preferred !== undefined
327
- ? [
328
- TRY_ORDER.find((c) => c.code === preferred),
329
- ...TRY_ORDER.filter((c) => c.code !== preferred),
330
- ]
331
- : TRY_ORDER.slice();
332
-
333
- let lastErr = null;
334
- const attempts = [];
335
- for (const cls of order) {
336
- try {
337
- const entries = await queryAll({
338
- tree, fileId, cls,
339
- encodeQueryDirectoryRequest,
340
- decodeQueryDirectoryResponse,
341
- QueryDirectoryFlag,
342
- SmbCommand,
343
- NTStatus,
344
- isSuccess,
345
- statusName,
346
- Reader,
347
- });
348
- // Cache the winning class for the next readdir on this client.
349
- PREFERRED_CLASS.set(rawClient, cls.code);
350
- return entries.map(dirEntryToPluginEntry);
351
- } catch (err) {
352
- lastErr = err;
353
- const msg = String((err && err.message) || err || "");
354
- attempts.push({ class: cls.name, code: cls.code, error: msg });
355
- // Only retry with a different class on OBJECT_NAME_INVALID / info-class
356
- // rejection — any other error (auth, connection, missing) is fatal.
357
- const isInfoClassBug =
358
- /0xC0000033|OBJECT_NAME_INVALID|STATUS_INVALID_INFO_CLASS/i.test(msg);
359
- if (!isInfoClassBug) {
360
- throw err;
361
- }
362
- // else: fall through to next class in order
363
- }
364
- }
365
- // All classes tried and all failed with 0xC0000033. Report a
366
- // synthesised error that is uniquely identifiable ("all classes
367
- // exhausted") so the caller can decide not to fall back to
368
- // smb3-client's own readdir (which uses class 37 again).
369
- const finalMsg =
370
- "QUERY_DIRECTORY failed: 0xC0000033 (all classes exhausted; " +
371
- "no working FileInformationClass on this server). Tried: " +
372
- attempts.map((a) => a.class + "=" + a.code).join(", ") + ".";
373
- const summary = new Error(finalMsg);
374
- summary.cause = lastErr;
375
- summary.attempts = attempts;
376
- throw summary;
377
- } finally {
378
- // Always close the handle. Ignore errors.
379
- try {
380
- const closeBody = encodeCloseRequest(fileId);
381
- const closeSigning = tree.session.makeSigning();
382
- await tree.conn.send(SmbCommand.CLOSE, closeBody, {
383
- sessionId: tree.session.sessionId,
384
- treeId: tree.treeId,
385
- ...(closeSigning !== undefined ? { signing: closeSigning } : {}),
386
- encrypt: tree.encryptRequired,
387
- creditCharge: 1,
388
- });
389
- } catch (_) {
390
- // best-effort
391
- }
392
- }
137
+ function encodeQueryDirectoryRequestFixed(I, req) {
138
+ const pat = req.searchPattern
139
+ ? Buffer.from(req.searchPattern, "utf16le")
140
+ : Buffer.alloc(0);
141
+ const w = new I.Writer();
142
+ w.u16(33); // StructureSize
143
+ w.u8(req.fileInformationClass);
144
+ w.u8(req.flags);
145
+ w.u32(req.fileIndex);
146
+ w.bytes(req.fileId); // 16 bytes
147
+ w.u16(pat.length === 0 ? 0 : 64 + 32);
148
+ w.u16(pat.length);
149
+ w.u32(req.outputBufferLength);
150
+ if (pat.length === 0) w.u8(0);
151
+ else w.bytes(pat);
152
+ return w.buffer();
393
153
  }
394
154
 
395
- // Send QUERY_DIRECTORY in a loop until STATUS_NO_MORE_FILES.
396
- async function queryAll(opts) {
397
- const {
398
- tree, fileId, cls,
399
- encodeQueryDirectoryRequest,
400
- decodeQueryDirectoryResponse,
401
- QueryDirectoryFlag,
402
- SmbCommand,
403
- NTStatus,
404
- isSuccess,
405
- statusName,
406
- Reader,
407
- } = opts;
408
-
155
+ /**
156
+ * QUERY_DIRECTORY-Loop mit gepatchtem Encoder.
157
+ * Nachbildung von smb3-client's readdirAll(), aber mit korrektem
158
+ * FileNameOffset auf Folge-Pages.
159
+ */
160
+ async function readdirAllFixed(I, open) {
409
161
  const items = [];
410
162
  let first = true;
411
163
  for (;;) {
412
- const body = encodeQueryDirectoryRequest({
413
- fileInformationClass: cls.code,
414
- flags: first ? QueryDirectoryFlag.RESTART_SCANS : 0,
164
+ const body = encodeQueryDirectoryRequestFixed(I, {
165
+ fileInformationClass:
166
+ I.FileInformationClass.FileIdBothDirectoryInformation,
167
+ flags: first ? I.QueryDirectoryFlag.RESTART_SCANS : 0,
415
168
  fileIndex: 0,
416
- fileId,
169
+ fileId: open.fileId,
417
170
  searchPattern: first ? "*" : "",
418
171
  outputBufferLength: 65536,
419
172
  });
420
173
  first = false;
421
- const signing = tree.session.makeSigning();
422
- const resp = await tree.conn.send(SmbCommand.QUERY_DIRECTORY, body, {
423
- sessionId: tree.session.sessionId,
424
- treeId: tree.treeId,
425
- ...(signing !== undefined ? { signing } : {}),
426
- encrypt: tree.encryptRequired,
427
- creditCharge: 1,
428
- });
429
- if (resp.header.status === NTStatus.STATUS_NO_MORE_FILES) break;
430
- if (!isSuccess(resp.header.status)) {
431
- const err = new Error(
432
- "QUERY_DIRECTORY failed: " + statusName(resp.header.status)
433
- );
434
- err.status = resp.header.status;
435
- throw err;
174
+
175
+ const signing = open.tree.session.makeSigning();
176
+ const resp = await open.tree.conn.send(
177
+ I.SmbCommand.QUERY_DIRECTORY,
178
+ body,
179
+ {
180
+ sessionId: open.tree.session.sessionId,
181
+ treeId: open.tree.treeId,
182
+ ...(signing !== undefined ? { signing } : {}),
183
+ encrypt: open.tree.encryptRequired,
184
+ creditCharge: 1,
185
+ }
186
+ );
187
+
188
+ if (resp.header.status === I.NTStatus.STATUS_NO_MORE_FILES) break;
189
+ if (!I.isSuccess(resp.header.status)) {
190
+ throw new I.SmbError({
191
+ status: resp.header.status,
192
+ message:
193
+ "QUERY_DIRECTORY (compat) failed: " +
194
+ I.statusName(resp.header.status),
195
+ });
436
196
  }
437
- const buf = decodeQueryDirectoryResponse(resp.body, 64);
197
+
198
+ const buf = I.decodeQueryDirectoryResponse(resp.body, 64);
438
199
  if (buf.length === 0) break;
439
- const page = cls.parse(buf, Reader);
200
+
201
+ const page = I.parseFileIdBothDirectoryInformation(buf);
440
202
  for (const e of page) items.push(e);
441
203
  if (page.length === 0) break;
442
204
  }
443
- // Filter "." and ".."
444
205
  return items.filter((x) => x.fileName !== "." && x.fileName !== "..");
445
206
  }
446
207
 
447
- // SMB file attribute bit for directory (MS-FSCC 2.6).
448
- const FILE_ATTRIBUTE_DIRECTORY = 0x10;
208
+ /**
209
+ * Öffentliche API: readdirCompat(client, fullPath, opts)
210
+ *
211
+ * Drop-in-Ersatz für `client.readdir(fullPath, opts)` von smb3-client.
212
+ * fullPath: "share/rest/of/path" (smb3-client-Konvention)
213
+ * opts: { withFileTypes?: boolean }
214
+ *
215
+ * Rückgabe:
216
+ * opts.withFileTypes === true → Dirent[] { name, isFile(), isDirectory() }
217
+ * sonst → string[] Dateinamen
218
+ *
219
+ * Zusätzlich (ergänzend zur smb3-client-API) Rich-Objects auf Wunsch:
220
+ * opts.rich === true → RichDirent[]
221
+ * { name, size, isFile(), isDirectory(), mtime, ctime, atime,
222
+ * changeTime, attributes, hidden, system, readonly, archive }
223
+ */
224
+ async function readdirCompat(client, fullPath, opts) {
225
+ const I = await loadInternals();
226
+
227
+ const { share, rest } = I.splitSharePath(fullPath);
228
+ const tree = await client.treeFor(share);
449
229
 
450
- // SMB time = 100-ns intervals since 1601-01-01. Convert to JS Date.
451
- function smbTimeToDate(u64) {
452
- if (u64 === undefined || u64 === null) return undefined;
453
- // u64 is a BigInt from Reader.u64()
454
- const ms = Number(u64 / 10000n) - 11644473600000;
455
- if (!isFinite(ms)) return undefined;
456
- return new Date(ms);
230
+ return I.Open.withOpen(
231
+ tree,
232
+ {
233
+ filename: I.toSmbPath(rest),
234
+ desiredAccess:
235
+ I.FileAccess.FILE_READ_DATA | I.FileAccess.FILE_READ_ATTRIBUTES,
236
+ shareAccess:
237
+ I.ShareAccess.READ | I.ShareAccess.WRITE | I.ShareAccess.DELETE,
238
+ createDisposition: I.CreateDisposition.OPEN,
239
+ createOptions: I.CreateOptions.DIRECTORY_FILE, // 1
240
+ fileAttributes: 0,
241
+ },
242
+ async (open) => {
243
+ const entries = await readdirAllFixed(I, open);
244
+ if (opts && opts.rich) {
245
+ return entries.map((e) => {
246
+ const attr = e.fileAttributes;
247
+ const isDir = (attr & I.FileAttribute.DIRECTORY) !== 0;
248
+ return {
249
+ name: e.fileName,
250
+ size: Number(e.endOfFile),
251
+ attributes: attr,
252
+ isFile: () => !isDir,
253
+ isDirectory: () => isDir,
254
+ mtime: I.smbTimeToDate(e.lastWriteTime),
255
+ ctime: I.smbTimeToDate(e.creationTime),
256
+ atime: I.smbTimeToDate(e.lastAccessTime),
257
+ changeTime: I.smbTimeToDate(e.changeTime),
258
+ hidden: (attr & 0x02) !== 0,
259
+ system: (attr & 0x04) !== 0,
260
+ readonly: (attr & 0x01) !== 0,
261
+ archive: (attr & 0x20) !== 0,
262
+ };
263
+ });
264
+ }
265
+ if (opts && opts.withFileTypes) {
266
+ return entries.map((e) => {
267
+ const isDir =
268
+ (e.fileAttributes & I.FileAttribute.DIRECTORY) !== 0;
269
+ return {
270
+ name: e.fileName,
271
+ isFile: () => !isDir,
272
+ isDirectory: () => isDir,
273
+ };
274
+ });
275
+ }
276
+ return entries.map((e) => e.fileName);
277
+ }
278
+ );
457
279
  }
458
280
 
459
- function dirEntryToPluginEntry(e) {
460
- const isDir = (e.fileAttributes & FILE_ATTRIBUTE_DIRECTORY) !== 0;
461
- return {
462
- name: e.fileName,
463
- isDirectory: isDir,
464
- size: isDir ? 0 : Number(e.endOfFile || 0n),
465
- mtime: smbTimeToDate(e.lastWriteTime),
466
- birthtime: smbTimeToDate(e.creationTime),
467
- ctime: smbTimeToDate(e.changeTime),
468
- };
281
+ /** Für Diagnose: Vorabladen erzwingen und Interna prüfen. */
282
+ async function ensureLoaded() {
283
+ await loadInternals();
284
+ return { loaded: true, smb3Root: INTERNALS.smb3Root };
469
285
  }
470
286
 
471
- module.exports = { readdirCompat };
287
+ module.exports = {
288
+ readdirCompat,
289
+ ensureLoaded,
290
+ };
package/smb-client.js CHANGED
@@ -224,38 +224,19 @@ async function buildClient(config) {
224
224
  }
225
225
 
226
226
  /**
227
- * Convert a smb3-client Dirent + best-effort stat into the shape the
228
- * rest of the plugin expects (`name`, `isDirectory` as function OR
229
- * boolean, `size`, `mtime`, `birthtime`).
230
- *
231
- * We fetch stat data in parallel because smb3-client's readdir Dirent
232
- * only carries name/isFile/isDirectory. For very large directories this
233
- * would fan out into many stat calls — that's an acceptable trade-off
234
- * for now; a future release can optimise by using SMB2_QUERY_DIRECTORY's
235
- * FileBothDirectoryInformation output directly.
227
+ * Convert a readdirCompat rich-Dirent (which already carries name,
228
+ * size, mtime, ctime, and isDirectory()) directly to the legacy
229
+ * `@marsaud/smb2`-compatible shape the rest of the plugin expects.
230
+ * No extra stat roundtrip needed — v0.4.12 gets attributes straight
231
+ * from the QUERY_DIRECTORY response.
236
232
  */
237
- async function enrichEntry(dirent, parentFullPath) {
238
- const isDir = !!dirent.isDirectory();
239
- const fullPath = parentFullPath
240
- ? parentFullPath + "/" + dirent.name
241
- : dirent.name;
242
- let size = 0;
243
- let mtime;
244
- let birthtime;
245
- try {
246
- const st = await client.stat(fullPath);
247
- size = Number(st.size || 0);
248
- mtime = st.mtime;
249
- birthtime = st.ctime;
250
- } catch (_) {
251
- // Non-fatal; return whatever we already have.
252
- }
233
+ function mapDirent(dirent) {
253
234
  return {
254
235
  name: dirent.name,
255
- isDirectory: isDir,
256
- size,
257
- mtime,
258
- birthtime,
236
+ isDirectory: !!(dirent.isDirectory && dirent.isDirectory()),
237
+ size: Number(dirent.size || 0),
238
+ mtime: dirent.mtime,
239
+ birthtime: dirent.ctime,
259
240
  };
260
241
  }
261
242
 
@@ -274,39 +255,18 @@ async function buildClient(config) {
274
255
  */
275
256
  async readdir(rel) {
276
257
  const full = resolvePath(rel);
277
- // v0.4.11: prefer the compat readdir (which tries multiple
278
- // FileInformationClass values). If it succeeds we are done — no
279
- // extra per-entry stat fan-out needed because compat returns rich
280
- // entries directly. If it fails, we ONLY fall back to smb3-client's
281
- // built-in readdir when the compat failure was not itself the
282
- // Samba-4.23 bug (0xC0000033 across all classes). If it *was*, the
283
- // built-in readdir will produce the exact same 0xC0000033 — no
284
- // point trying it, and it would hide the diagnostics compat has
285
- // gathered. Instead we rethrow the compat error verbatim so the
286
- // caller sees the true root cause.
287
- let compatErr = null;
288
- try {
289
- return await readdirCompat(client, full);
290
- } catch (err) {
291
- compatErr = err;
292
- const compatMsg = String((err && err.message) || err || "");
293
- // If compat ran into the exact same info-class rejection with
294
- // ALL classes exhausted, the built-in readdir cannot possibly
295
- // help — it uses class 37, which compat already tried. Rethrow
296
- // now so callers see the real reason.
297
- if (/0xC0000033|OBJECT_NAME_INVALID/i.test(compatMsg) &&
298
- /all classes exhausted|no working FileInformationClass/i.test(compatMsg)) {
299
- throw err;
300
- }
301
- // Otherwise (compat failed for structural reasons like an unknown
302
- // internal-module path, or a transient socket error) fall through
303
- // to the classic path so the existing German error rewrites still
304
- // apply.
305
- }
258
+ // v0.4.12: readdirCompat ist der ALLEINIGE readdir-Pfad. Grund:
259
+ // smb3-client@0.2.0 hat einen Wire-Format-Bug in
260
+ // encodeQueryDirectoryRequest, der gegen Samba 4.23 zu
261
+ // STATUS_OBJECT_NAME_INVALID (0xC0000033) führt. readdirCompat
262
+ // implementiert QUERY_DIRECTORY MS-SMB2-§2.2.33-konform und
263
+ // umgeht damit den Bug. Es gibt keinen Fallback auf
264
+ // client.readdir(), weil das den bekannten Bug hervorruft.
306
265
  let dirents;
307
266
  try {
308
- dirents = await client.readdir(full, { withFileTypes: true });
309
- } catch (err) {
267
+ dirents = await readdirCompat(client, full, { rich: true });
268
+ } catch (errCompat) {
269
+ const err = errCompat;
310
270
  // Some Samba builds (observed on 4.20+/4.23+) reject
311
271
  // SMB2 QUERY_DIRECTORY on the *share root* with
312
272
  // STATUS_OBJECT_NAME_INVALID (0xC0000033) because smb3-client
@@ -318,18 +278,20 @@ async function buildClient(config) {
318
278
  // require the caller to configure a real base_path so every
319
279
  // readdir happens inside a directory the server can enumerate.
320
280
  const msg = String((err && err.message) || err || "");
321
- const isRoot = !rel && !basePath;
322
- const isRootEnumBug =
323
- /0xC0000033|OBJECT_NAME_INVALID|QUERY_DIRECTORY/i.test(msg);
324
- if (isRoot && isRootEnumBug) {
281
+ // Falls der Compat-Pfad selbst mit 0xC0000033 zurückkommt,
282
+ // liegt entweder ein weiterer, unbekannter Wire-Bug vor oder
283
+ // Samba lehnt aus einem anderen Grund ab. Wir geben eine
284
+ // gezielte Meldung mit Hinweis auf den Bug-Report.
285
+ if (/0xC0000033|OBJECT_NAME_INVALID/i.test(msg)) {
325
286
  const e = new Error(
326
- "Das Share-Root lässt sich auf diesem Samba-Server nicht " +
327
- "direkt auflisten (" + (msg || "QUERY_DIRECTORY failed") + "). " +
328
- "Bitte in der Plugin-Config einen Basispfad setzen (z.\u202fB. " +
329
- "einen Unterordner der Freigabe wie „daten“ oder „projekte“) " +
330
- "dann funktionieren alle Directory-Listings innerhalb dieses " +
331
- "Unterordners. Details siehe README, Abschnitt „Troubleshooting → " +
332
- "QUERY_DIRECTORY auf Share-Root“."
287
+ "Samba lehnt QUERY_DIRECTORY auf diesem Pfad ab " +
288
+ "(STATUS_OBJECT_NAME_INVALID, 0xC0000033). " +
289
+ "Der bekannte smb3-client-Wire-Bug wurde in v0.4.12 gefixt — " +
290
+ "falls dieser Fehler weiterhin auftritt, liegt ein zweiter, " +
291
+ "noch unbekannter Wire-Format-Fehler vor. Bitte " +
292
+ "tools/diag-basepath.js ausführen und Report zurückschicken. " +
293
+ "Details: " +
294
+ msg
333
295
  );
334
296
  e.cause = err;
335
297
  throw e;
@@ -358,18 +320,10 @@ async function buildClient(config) {
358
320
  }
359
321
  throw err;
360
322
  }
361
- // Parallel enrichment. Bounded to a reasonable concurrency to avoid
362
- // saturating the SMB session on huge directories.
363
- const CHUNK = 16;
364
- const result = [];
365
- for (let i = 0; i < dirents.length; i += CHUNK) {
366
- const slice = dirents.slice(i, i + CHUNK);
367
- const enriched = await Promise.all(
368
- slice.map((d) => enrichEntry(d, full))
369
- );
370
- result.push(...enriched);
371
- }
372
- return result;
323
+ // v0.4.12: readdirCompat gibt schon Rich-Dirents zurück (Name,
324
+ // Größe, mtime, ctime, isDirectory). Kein zusätzlicher stat-Fan-out
325
+ // nötig ein einziger QUERY_DIRECTORY-Roundtrip liefert alles.
326
+ return dirents.map(mapDirent);
373
327
  },
374
328
 
375
329
  /** Stat a single file/directory (name-relative-to-share/basePath). */