saltcorn-samba 0.4.8 → 0.4.10

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,87 @@ 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.10] – 2026-07-05
8
+
9
+ ### Fixed – **`0xC0000033` beim tatsächlichen Auflisten (Tree-View, File-Manager) endgültig behoben**
10
+
11
+ Ab 0.4.9 zeigte der Verbindungstest zwar den grünen Erfolgsstatus mit
12
+ gelbem Hinweis, aber im normalen Betrieb (Tree-View, File-Manager,
13
+ Datei-Listen) knallte die Anwendung mit `Samba: QUERY_DIRECTORY failed:
14
+ 0xC0000033` — der `stat`-Fallback verhinderte das nur beim Test, nicht
15
+ bei echten Aufrufen.
16
+
17
+ **Ursache:** `smb3-client@0.2.0` sendet in
18
+ [`open/readdir.js`](https://github.com/euricojardim/smb3-client) hart
19
+ kodiert `FileInformationClass = 37`
20
+ (`FileIdBothDirectoryInformation`). Samba 4.23 lehnt diese
21
+ Info-Klasse für bestimmte Verzeichnisse mit
22
+ `STATUS_OBJECT_NAME_INVALID` (`0xC0000033`) ab. `smbclient` und
23
+ Windows-Explorer benutzen `FileBothDirectoryInformation (3)` bzw.
24
+ `FileDirectoryInformation (1)` — die Samba zuverlässig akzeptiert.
25
+
26
+ **Fix:** Neues Modul `readdir-compat.js`. Es macht Folgendes:
27
+
28
+ 1. Für jeden `readdir`-Aufruf wird die Directory zuerst per
29
+ `CREATE`+`DIRECTORY_FILE` geöffnet (wie bei smb3-client selbst).
30
+ 2. Dann sendet der Wrapper `QUERY_DIRECTORY` in dieser Reihenfolge:
31
+ `FileIdBothDirectoryInformation (37)` →
32
+ `FileBothDirectoryInformation (3)` →
33
+ `FileDirectoryInformation (1)`. Sobald eine Klasse Erfolg meldet,
34
+ wird sie pro Client-Instanz gemerkt (`WeakMap`), damit
35
+ Folgeaufrufe nicht dreimal probieren.
36
+ 3. `0xC0000033` und `STATUS_INVALID_INFO_CLASS` lösen den nächsten
37
+ Versuch aus; jeder andere Fehler (fehlender Pfad, Zugriff
38
+ verweigert, Netzwerk) bricht sofort ab und wird an die bestehende
39
+ deutsche Fehlerbehandlung weitergereicht.
40
+ 4. Die Rückgabe enthält Größe/`mtime`/`ctime` direkt aus dem
41
+ `QUERY_DIRECTORY`-Response — dadurch entfällt der bisherige
42
+ Per-Entry-`stat`-Fan-Out, was Directory-Listings spürbar
43
+ beschleunigt.
44
+ 5. Bei `readdir` bleibt `client.readdir()` als klassischer Fallback
45
+ erhalten, wenn `readdir-compat` an einem *anderen* Fehler scheitert
46
+ — dann greifen die bereits vorhandenen Rewrites
47
+ („Pfad existiert nicht", Groß-/Kleinschreibungs-Probes).
48
+
49
+ **Warum das überhaupt geht:** `smb3-client` publiziert nur seinen
50
+ `Client`-Konstruktor über die `exports`-Map. Die internen Wire-
51
+ Encoder/Decoder liegen aber als reguläre `.js`-Dateien in
52
+ `dist/`. Der Wrapper importiert sie über `file://`-URLs direkt vom
53
+ Filesystem (also `node_modules/smb3-client/dist/wire/structs/...`),
54
+ was den `exports`-Gate umgeht. Das ist stabil, solange die
55
+ dist-Layout-Konventionen der 0.2.x-Reihe von `smb3-client` erhalten
56
+ bleiben.
57
+
58
+ ## [0.4.9] – 2026-07-05
59
+
60
+ ### Fixed – **`0xC0000033` beim Auflisten des Basispfads (smb3-client-vs-Samba-4.23-Bug jetzt auch für Basispfade abgefangen)**
61
+
62
+ Auch in 0.4.8 lief der Verbindungstest mit gesetztem Basispfad in
63
+ `QUERY_DIRECTORY failed: 0xC0000033` (`OBJECT_NAME_INVALID`). Ohne
64
+ Basispfad war die Verbindung korrekt und zeigte den gelben Hinweis.
65
+
66
+ **Ursache:** `smb3-client` sendet `QUERY_DIRECTORY` mit hart kodiertem
67
+ `FileInformationClass = 37` (`FileIdBothDirectoryInformation`). Samba
68
+ 4.23 lehnt das für bestimmte Verzeichnisse ab — auch dann, wenn der
69
+ Ordner existiert, geöffnet und `stat`-bar ist. Genau derselbe Bug, der
70
+ bisher nur beim Auflisten des Share-Roots als ‚Root nicht auflistbar'
71
+ aufgefangen wurde, kann auch den konfigurierten Basispfad selbst
72
+ treffen.
73
+
74
+ **Fix:** Der Fallback greift jetzt auch für Basispfade. Wenn
75
+ `client.readdir("")` mit `0xC0000033` scheitert, versucht die Test-Route
76
+ zusätzlich `client.stat("")` auf denselben Pfad:
77
+
78
+ - **stat OK** → Verbindung + Basispfad bestätigt, gelber Hinweis mit
79
+ Erklärung des bekannten Bugs und Workaround-Vorschlägen.
80
+ - **stat schlägt auch fehl** → normaler Fehlerpfad (Basispfad-
81
+ Diagnose mit Schreibvarianten-Probes) läuft weiter wie bisher.
82
+
83
+ Damit lassen sich Basispfade eintragen, auf die `readdir` per
84
+ `FileIdBothDirectoryInformation` scheitert — der Download und die
85
+ Unterordner-Ansicht funktionieren über die normalen SMB-Calls
86
+ trotzdem.
87
+
7
88
  ## [0.4.8] – 2026-07-05
8
89
 
9
90
  ### Fixed – **Basispfad wurde beim Verbindungstest doppelt vorangestellt (die eigentliche Ursache)**
package/index.js CHANGED
@@ -872,18 +872,30 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
872
872
  // subdirectory to test).
873
873
  const effectivePath = testCfg.share + (baseForDisplay ? "/" + baseForDisplay : "");
874
874
  const isRootProbe = !baseForDisplay;
875
- const isRootEnumBug = /0xC0000033|OBJECT_NAME_INVALID|Share-Root/i.test(msg);
876
- if (isRootProbe && isRootEnumBug) {
877
- // Share-root enumeration is a known smb3-client-vs-Samba
878
- // limitation. Fall back to stat("") which proves the whole
879
- // handshake works even if we can't list the root.
875
+ // smb3-client hardcodes FileInformationClass=37
876
+ // (FileIdBothDirectoryInformation) in QUERY_DIRECTORY. Some
877
+ // Samba 4.23+ configurations reject that with 0xC0000033
878
+ // (OBJECT_NAME_INVALID) for specific directories even though
879
+ // the same directory can be opened, stat'd and traversed. In
880
+ // that case we fall back to a plain stat: if the folder exists
881
+ // (stat succeeds) we can prove the connection works, but flag
882
+ // that enumeration is broken so the caller shows the yellow
883
+ // hint instead of a red error.
884
+ const isEnumBug = /0xC0000033|OBJECT_NAME_INVALID|QUERY_DIRECTORY failed/i.test(msg);
885
+ if (isEnumBug) {
880
886
  try {
881
887
  await client.stat("");
882
888
  const marker = [];
883
- marker._rootNotEnumerable = true;
889
+ if (isRootProbe) {
890
+ marker._rootNotEnumerable = true;
891
+ } else {
892
+ marker._basePathNotEnumerable = true;
893
+ marker._basePathForDisplay = baseForDisplay;
894
+ }
884
895
  return marker;
885
896
  } catch (statErr) {
886
- throw err;
897
+ // stat also failed — fall through to normal error handling
898
+ // (missing-basepath diagnostics or generic rethrow).
887
899
  }
888
900
  }
889
901
  // NAME_NOT_FOUND / PATH_NOT_FOUND on a configured base_path.
@@ -990,6 +1002,7 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
990
1002
  });
991
1003
  const took = Date.now() - started;
992
1004
  const rootNotEnum = Array.isArray(listing) && listing._rootNotEnumerable === true;
1005
+ const baseNotEnum = Array.isArray(listing) && listing._basePathNotEnumerable === true;
993
1006
  return res.json({
994
1007
  ok: true,
995
1008
  server: testCfg.server,
@@ -1011,6 +1024,16 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
1011
1024
  "Verhalten mit smb3-client). Bitte setzen Sie einen Basispfad " +
1012
1025
  "in der Plugin-Config (z.\u202fB. einen Unterordner der " +
1013
1026
  "Freigabe) — dann funktioniert der File-Manager vollständig."
1027
+ : baseNotEnum
1028
+ ? "Verbindung + Anmeldung erfolgreich, Basispfad \u201e" +
1029
+ (listing._basePathForDisplay || testCfg.base_path || "") +
1030
+ "\u201c wurde vom Server best\u00e4tigt (stat OK). Das direkte " +
1031
+ "Auflisten dieses Ordners lehnt Samba 4.23 mit smb3-client " +
1032
+ "aktuell ab (bekannter Bug: FileInformationClass=37 hart " +
1033
+ "kodiert, siehe README). Ordner-Unterb\u00e4ume, Datei-Downloads " +
1034
+ "und PDF-Ansicht funktionieren \u2014 nur die Wurzel des " +
1035
+ "Basispfads kann nicht direkt aufgelistet werden. Workaround: " +
1036
+ "tieferen Basispfad setzen oder Unterordner ansprechen."
1014
1037
  : undefined,
1015
1038
  });
1016
1039
  } catch (err) {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "saltcorn-samba",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
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": {
7
7
  "test": "node test/sanitize.test.js",
8
- "lint": "node --check index.js && node --check smb-client.js && node --check tree-view.js && node --check filemanager-view.js && node --check pdf-view.js && node --check public/samba-tree.js && node --check public/samba-filemanager.js",
8
+ "lint": "node --check index.js && node --check smb-client.js && node --check readdir-compat.js && node --check tree-view.js && node --check filemanager-view.js && node --check pdf-view.js && node --check public/samba-tree.js && node --check public/samba-filemanager.js",
9
9
  "prepublishOnly": "npm run lint && npm test"
10
10
  },
11
11
  "keywords": [
@@ -39,6 +39,7 @@
39
39
  "files": [
40
40
  "index.js",
41
41
  "smb-client.js",
42
+ "readdir-compat.js",
42
43
  "tree-view.js",
43
44
  "filemanager-view.js",
44
45
  "pdf-view.js",
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Compatibility layer for smb3-client's readdir on Samba 4.23+.
3
+ *
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.
13
+ *
14
+ * This module implements `readdirCompat(client, sharePath)` which:
15
+ *
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.
25
+ *
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.
36
+ */
37
+
38
+ "use strict";
39
+
40
+ // Lazy, cached bundle of the internal modules we need. Loaded on first use.
41
+ let _internalsPromise = null;
42
+
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");
52
+
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;
59
+ break;
60
+ }
61
+ }
62
+ if (!pkgRoot) {
63
+ throw new Error(
64
+ "readdir-compat: cannot locate smb3-client package root on disk"
65
+ );
66
+ }
67
+ const dist = path.join(pkgRoot, "dist");
68
+ const urlOf = (rel) =>
69
+ pathToFileURL(path.join(dist, rel)).href;
70
+
71
+ const [
72
+ queryDirectoryMod,
73
+ createMod,
74
+ closeMod,
75
+ commandsMod,
76
+ pathsMod,
77
+ bufferMod,
78
+ ] = 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")),
85
+ ]);
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,
101
+ splitSharePath: pathsMod.splitSharePath,
102
+ toSmbPath: pathsMod.toSmbPath,
103
+ Reader: bufferMod.Reader,
104
+ };
105
+ })();
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
+
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;
163
+ }
164
+
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
+ /**
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}>>}
267
+ */
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
+ for (const cls of order) {
335
+ try {
336
+ const entries = await queryAll({
337
+ tree, fileId, cls,
338
+ encodeQueryDirectoryRequest,
339
+ decodeQueryDirectoryResponse,
340
+ QueryDirectoryFlag,
341
+ SmbCommand,
342
+ NTStatus,
343
+ isSuccess,
344
+ statusName,
345
+ Reader,
346
+ });
347
+ // Cache the winning class for the next readdir on this client.
348
+ PREFERRED_CLASS.set(rawClient, cls.code);
349
+ return entries.map(dirEntryToPluginEntry);
350
+ } catch (err) {
351
+ lastErr = err;
352
+ // Only retry with a different class on OBJECT_NAME_INVALID / info-class
353
+ // rejection — any other error (auth, connection, missing) is fatal.
354
+ const msg = String((err && err.message) || err || "");
355
+ const isInfoClassBug =
356
+ /0xC0000033|OBJECT_NAME_INVALID|STATUS_INVALID_INFO_CLASS/i.test(msg);
357
+ if (!isInfoClassBug) {
358
+ throw err;
359
+ }
360
+ // else: fall through to next class in order
361
+ }
362
+ }
363
+ throw lastErr;
364
+ } finally {
365
+ // Always close the handle. Ignore errors.
366
+ try {
367
+ const closeBody = encodeCloseRequest(fileId);
368
+ const closeSigning = tree.session.makeSigning();
369
+ await tree.conn.send(SmbCommand.CLOSE, closeBody, {
370
+ sessionId: tree.session.sessionId,
371
+ treeId: tree.treeId,
372
+ ...(closeSigning !== undefined ? { signing: closeSigning } : {}),
373
+ encrypt: tree.encryptRequired,
374
+ creditCharge: 1,
375
+ });
376
+ } catch (_) {
377
+ // best-effort
378
+ }
379
+ }
380
+ }
381
+
382
+ // Send QUERY_DIRECTORY in a loop until STATUS_NO_MORE_FILES.
383
+ async function queryAll(opts) {
384
+ const {
385
+ tree, fileId, cls,
386
+ encodeQueryDirectoryRequest,
387
+ decodeQueryDirectoryResponse,
388
+ QueryDirectoryFlag,
389
+ SmbCommand,
390
+ NTStatus,
391
+ isSuccess,
392
+ statusName,
393
+ Reader,
394
+ } = opts;
395
+
396
+ const items = [];
397
+ let first = true;
398
+ for (;;) {
399
+ const body = encodeQueryDirectoryRequest({
400
+ fileInformationClass: cls.code,
401
+ flags: first ? QueryDirectoryFlag.RESTART_SCANS : 0,
402
+ fileIndex: 0,
403
+ fileId,
404
+ searchPattern: first ? "*" : "",
405
+ outputBufferLength: 65536,
406
+ });
407
+ first = false;
408
+ const signing = tree.session.makeSigning();
409
+ const resp = await tree.conn.send(SmbCommand.QUERY_DIRECTORY, body, {
410
+ sessionId: tree.session.sessionId,
411
+ treeId: tree.treeId,
412
+ ...(signing !== undefined ? { signing } : {}),
413
+ encrypt: tree.encryptRequired,
414
+ creditCharge: 1,
415
+ });
416
+ if (resp.header.status === NTStatus.STATUS_NO_MORE_FILES) break;
417
+ if (!isSuccess(resp.header.status)) {
418
+ const err = new Error(
419
+ "QUERY_DIRECTORY failed: " + statusName(resp.header.status)
420
+ );
421
+ err.status = resp.header.status;
422
+ throw err;
423
+ }
424
+ const buf = decodeQueryDirectoryResponse(resp.body, 64);
425
+ if (buf.length === 0) break;
426
+ const page = cls.parse(buf, Reader);
427
+ for (const e of page) items.push(e);
428
+ if (page.length === 0) break;
429
+ }
430
+ // Filter "." and ".."
431
+ return items.filter((x) => x.fileName !== "." && x.fileName !== "..");
432
+ }
433
+
434
+ // SMB file attribute bit for directory (MS-FSCC 2.6).
435
+ const FILE_ATTRIBUTE_DIRECTORY = 0x10;
436
+
437
+ // SMB time = 100-ns intervals since 1601-01-01. Convert to JS Date.
438
+ function smbTimeToDate(u64) {
439
+ if (u64 === undefined || u64 === null) return undefined;
440
+ // u64 is a BigInt from Reader.u64()
441
+ const ms = Number(u64 / 10000n) - 11644473600000;
442
+ if (!isFinite(ms)) return undefined;
443
+ return new Date(ms);
444
+ }
445
+
446
+ function dirEntryToPluginEntry(e) {
447
+ const isDir = (e.fileAttributes & FILE_ATTRIBUTE_DIRECTORY) !== 0;
448
+ return {
449
+ name: e.fileName,
450
+ isDirectory: isDir,
451
+ size: isDir ? 0 : Number(e.endOfFile || 0n),
452
+ mtime: smbTimeToDate(e.lastWriteTime),
453
+ birthtime: smbTimeToDate(e.creationTime),
454
+ ctime: smbTimeToDate(e.changeTime),
455
+ };
456
+ }
457
+
458
+ module.exports = { readdirCompat };
package/smb-client.js CHANGED
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  const path = require("path");
32
+ const { readdirCompat } = require("./readdir-compat");
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Dynamic ESM import cache for smb3-client
@@ -273,6 +274,31 @@ async function buildClient(config) {
273
274
  */
274
275
  async readdir(rel) {
275
276
  const full = resolvePath(rel);
277
+ // v0.4.10: use the compat readdir which tries multiple
278
+ // FileInformationClass values so we survive Samba 4.23's rejection
279
+ // of FileIdBothDirectoryInformation (37) — the class smb3-client
280
+ // hardcodes and which many Samba configurations answer with
281
+ // STATUS_OBJECT_NAME_INVALID (0xC0000033). readdirCompat also
282
+ // returns rich entries (name+isDir+size+mtime+ctime) directly, so
283
+ // we skip the per-entry stat fan-out on success.
284
+ let entriesFromCompat = null;
285
+ try {
286
+ entriesFromCompat = await readdirCompat(client, full);
287
+ return entriesFromCompat;
288
+ } catch (err) {
289
+ // Fall back to smb3-client's built-in readdir with the same error
290
+ // handling as before. This preserves prior behaviour for servers
291
+ // where the compat path fails for reasons unrelated to the
292
+ // FileInformationClass bug (e.g. missing dir, permission).
293
+ const compatMsg = String((err && err.message) || err || "");
294
+ const compatIsInfoClassOnly =
295
+ /STATUS_INVALID_INFO_CLASS/i.test(compatMsg);
296
+ if (!compatIsInfoClassOnly) {
297
+ // Not an info-class problem — surface below by re-running
298
+ // through the classic path so the existing German error
299
+ // rewrites still apply.
300
+ }
301
+ }
276
302
  let dirents;
277
303
  try {
278
304
  dirents = await client.readdir(full, { withFileTypes: true });