saltcorn-samba 0.3.11 → 0.4.0

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,100 @@ 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.0] – 2026-07-05
8
+
9
+ ### ⚠️ BREAKING CHANGES
10
+
11
+ - **Node.js ≥ 20 wird jetzt zwingend benötigt** (vorher war 16 möglich).
12
+ Grund: die neue Dependency `smb3-client` ist ein Pure-ESM-Paket und wird
13
+ im Plugin über dynamic `import()` aus CommonJS geladen — das ist ab
14
+ Node 20 stabil.
15
+ - **Dependency-Wechsel:** `@marsaud/smb2` wurde vollständig entfernt und
16
+ durch [`smb3-client`](https://www.npmjs.com/package/smb3-client) `^0.2.0`
17
+ ersetzt.
18
+ - **Zwei neue Config-Felder** in Schritt 1 des Konfigurations-Wizards:
19
+ - `SMB-Signing` (Werte: `if-offered` / `required` / `disabled`, Default `if-offered`)
20
+ - `SMB-Verschlüsselung` (Werte: `if-offered` / `required` / `disabled`, Default `if-offered`)
21
+
22
+ Es ist **kein manueller Config-Umbau nötig** — die Defaults sind für
23
+ fast alle Setups sinnvoll. Wer aus Sicherheitsgründen Signing oder
24
+ Encryption erzwingen will, kann jetzt `required` wählen.
25
+
26
+ ### Fixed – **Moderne Samba-Server (`sign_algo_id=0` / AES-CMAC-Pflicht)**
27
+
28
+ Symptom nach dem 0.3.11-Update: nach dem Setzen von
29
+ `NODE_OPTIONS=--openssl-legacy-provider` startete Saltcorn wieder, aber im
30
+ Samba-Log erschien beim ersten Zugriff:
31
+
32
+ ```
33
+ smbd: sign_algo_id=0 in negotiate response, expected AES-CMAC (2) or higher
34
+ ```
35
+
36
+ gefolgt von `STATUS_INVALID_PARAMETER` und einem 401-Fehler im Plugin.
37
+
38
+ **Ursache:** Aktuelle Samba-Versionen (Ubuntu 22.04+ / Debian 12+ /
39
+ RHEL 9+) verlangen im SMB-3.1.1-Handshake das moderne Signing-Verfahren
40
+ **AES-128-CMAC** (`SigningAlgorithmId 2`). `@marsaud/smb2` (letztes
41
+ Release 2020, unmaintained) implementiert diese Cipher-Suite nicht — es
42
+ kann nur die alten HMAC-SHA256- bzw. HMAC-MD5-Signaturen und meldet
43
+ sich mit `sign_algo_id=0`, was ein moderner Samba als Protokoll-Fehler
44
+ verwirft.
45
+
46
+ **Lösung — kompletter Wechsel der SMB-Client-Library:**
47
+
48
+ | Aspekt | Vorher (`@marsaud/smb2`) | Nachher (`smb3-client` 0.2.0) |
49
+ |---|---|---|
50
+ | Protokoll | SMB 2.0 / 2.1 | SMB 2.1 / 3.0 / 3.0.2 / **3.1.1** |
51
+ | Signing | HMAC-SHA256 (nur) | HMAC-SHA256 **+ AES-128-CMAC** |
52
+ | Encryption | – | AES-128-CCM / AES-128-GCM (optional) |
53
+ | Pre-Auth | – | SHA-512 Pre-Auth-Integrity |
54
+ | Auth | NTLM (via `ntlm`, DES-ECB) | **NTLMv2 / SPNEGO** (ohne DES-ECB) |
55
+ | Wartung | Letztes Release Q4 2020 | Aktiv (Mai 2026) |
56
+ | Runtime-Deps | `ntlm`, `iconv-lite` | **keine** |
57
+ | API | Callback | **Promise / async** |
58
+ | Modul-System | CommonJS | Pure ESM (via dynamic import geladen) |
59
+
60
+ ### Removed
61
+
62
+ - Legacy-Crypto-Check-Utility (`checkLegacyCryptoAvailable`,
63
+ `legacyCryptoErrorMessage`) sowie zugehöriger Handshake-Block in der
64
+ `/sambatest`-Route und im Test-Panel — nicht mehr nötig, weil
65
+ `smb3-client` keine Legacy-Cipher (DES-ECB) verwendet.
66
+ - Troubleshooting-Abschnitt zum `--openssl-legacy-provider`-Flag im README
67
+ (durch neuen 0.4.0-Kompatibilitätsabschnitt ersetzt).
68
+
69
+ ### Changed
70
+
71
+ - `smb-client.js` komplett neu geschrieben als dünner Wrapper um
72
+ `smb3-client`. Interne API (`buildClient`, `withClient`, `readdir`,
73
+ `readFile`, `writeFile`, `rename`, `unlink`, `mkdir`, `rmdir`) bleibt
74
+ identisch — kein Anpassungsbedarf in den View- oder Route-Handlern.
75
+ - Pfad-Konvention intern umgestellt: `smb3-client` erwartet den Share als
76
+ erstes Segment jedes Pfads (`share/subdir/datei.pdf`) statt separat im
77
+ Constructor. Der Wrapper prependet den Share-Namen transparent, sodass
78
+ Aufrufer weiterhin nur relative Pfade (`subdir/datei.pdf`) übergeben.
79
+ - Readdir-Ergebnisse werden pro Eintrag mit einem parallelen `stat()`-
80
+ Aufruf (Batch-Größe 16) angereichert, weil `smb3-client`-Dirents nur
81
+ `name` + `isFile()` + `isDirectory()` liefern — der Plugin-Filemanager
82
+ braucht aber weiterhin Größe und mtime.
83
+ - Fehler-Mapping in `/sambatest` erweitert um typische Signing-/
84
+ Encryption-/Pre-Auth-Fehlermeldungen aus `smb3-client` (`bad signature`,
85
+ `preauth integrity`, `encryption`).
86
+
87
+ ### Migration
88
+
89
+ 1. Saltcorn stoppen.
90
+ 2. Plugin auf 0.4.0 aktualisieren (npm bzw. neues ZIP entpacken).
91
+ 3. Falls gesetzt: `NODE_OPTIONS=--openssl-legacy-provider` **entfernen**
92
+ (nicht mehr nötig; siehe README-Abschnitt „Troubleshooting →
93
+ ERR_OSSL_EVP_UNSUPPORTED").
94
+ 4. Sicherstellen, dass **Node.js ≥ 20** installiert ist (`node -v`).
95
+ 5. Saltcorn wieder starten. Die Plugin-Config bleibt gültig — die neuen
96
+ Felder `SMB-Signing` und `SMB-Verschlüsselung` erhalten automatisch
97
+ den Default `if-offered`. Wer maximale Sicherheit möchte, setzt beide
98
+ auf `required` (Voraussetzung: der Samba-Server unterstützt es).
99
+ 6. Im Config-Wizard einmal „→ Verbindung jetzt testen" klicken.
100
+
7
101
  ## [0.3.11] – 2026-07-05
8
102
 
9
103
  ### Fixed – **Worker-Crash / 502 durch DES-ECB auf Node 17+ / OpenSSL 3**
package/README.md CHANGED
@@ -12,7 +12,24 @@ Features:
12
12
  - 📄 **Inline-PDF-/Bild-Anzeige** direkt im `SambaFileManager` (Klick auf eine Datei), plus Download- und externe-App-Buttons.
13
13
  - 🚀 **`smb://`-Links** — Öffnet Dateien und Ordner direkt in Nemo, Nautilus, Dolphin (Linux) oder Explorer (Windows).
14
14
  - 🔒 **Sicherheit** — Base-Path als „Chroot", strenge Path-Traversal-Prüfung (`..`, absolute Pfade, UNC, Drive-Letters, NUL-Bytes), CSRF-Schutz auf Schreib-Routen, Filename-Sanitizer, Extension-Blocklist, rollenbasierter Zugriff (getrennte Lese-/Schreib-Rollen).
15
- - 🐳 **Docker-freundlich** — direkt per SMB2-Protokoll, keine System-Binaries nötig.
15
+ - 🔐 **Moderne SMB-Kryptografie** — SMB 3.1.1 mit **AES-128-CMAC-Signing** (Pflicht auf aktuellen Samba-Servern) und optionaler **AES-CCM/GCM-Verschlüsselung**. Signing- und Encryption-Modus per Config-Dropdown wählbar (`if-offered` / `required` / `disabled`). **Keine Legacy-Cipher** (DES-ECB) mehr nötig — läuft ohne `--openssl-legacy-provider` unter Node 20+.
16
+ - 🐳 **Docker-freundlich** — direkt per SMB2/3-Protokoll, keine System-Binaries nötig.
17
+
18
+ > **Voraussetzungen:** **Node.js ≥ 20** (wegen der ESM-basierten [`smb3-client`](https://www.npmjs.com/package/smb3-client) Library, die intern per dynamic `import()` geladen wird). Saltcorn 0.9+ wird unterstützt.
19
+
20
+ ---
21
+
22
+ ## Was ist neu in 0.4.0 (Migration von 0.3.x)
23
+
24
+ Das Plugin wurde intern von der unmaintained `@marsaud/smb2` auf die moderne
25
+ `smb3-client` umgestellt. **Kein Config-Umbau nötig** — die neuen Felder
26
+ `SMB-Signing` und `SMB-Verschlüsselung` haben sinnvolle Defaults (`if-offered`).
27
+ Dadurch:
28
+
29
+ - ✅ funktioniert wieder gegen moderne Samba-Server, die `sign_algo_id != 0`
30
+ (AES-CMAC) verlangen,
31
+ - ✅ **kein** `--openssl-legacy-provider`-Flag mehr nötig,
32
+ - ⚠️ **Breaking:** Node.js ≥ 20 zwingend erforderlich (vorher 16 möglich).
16
33
 
17
34
  ---
18
35
 
@@ -109,6 +126,8 @@ Configure* ausfüllen. Die Konfiguration ist zweistufig:
109
126
  | **Passwort** | *(secret)* | Samba nutzt ein eigenes Passwort (`smbpasswd`), nicht zwingend das Linux-Login. Moderne Server lehnen leere Passwörter ab. |
110
127
  | **Basispfad** | `projekte/2026` | Optional. Relativ, mit Slashes, **ohne** führenden `/`. Beschränkt jeden Zugriff auf dieses Unterverzeichnis der Freigabe. `..` und absolute Pfade werden abgelehnt. |
111
128
  | **TCP-Port** | `445` | Standard SMB2/3 über TCP. **SMBv1 (139) wird nicht unterstützt** – auf dem Server `min protocol = SMB2` setzen. |
129
+ | **SMB-Signing** | `if-offered` | `if-offered` (Standard), `required` oder `disabled`. Nutzt HMAC-SHA256 (SMB 2.x) bzw. AES-128-CMAC (SMB 3.x). Moderne Samba-Server verlangen häufig Signing → `required` oder `if-offered`. |
130
+ | **SMB-Verschlüsselung** | `if-offered` | `if-offered` (Standard), `required` oder `disabled`. Nutzt AES-128/256-CCM/GCM. Shares mit serverseitigem `SMB2_SHAREFLAG_ENCRYPT_DATA` erzwingen Verschlüsselung ohnehin. |
112
131
 
113
132
  > **Tipp:** Bevor Sie speichern, klicken Sie auf **„→ Verbindung jetzt
114
133
  > testen“** – siehe Abschnitt [Verbindung testen](#verbindung-testen).
@@ -333,89 +352,67 @@ Ab dann taucht das Plugin im Plugins-Store jeder Saltcorn-Instanz auf.
333
352
 
334
353
  ## Troubleshooting
335
354
 
336
- ### `ERR_OSSL_EVP_UNSUPPORTED` / `digital envelope routines::unsupported` / Worker stirbt / 502 Bad Gateway
337
-
338
- Die Bibliothek `@marsaud/smb2` benutzt intern (im transitiven Paket `ntlm`)
339
- den Cipher **DES-ECB**, um die NTLM/LM-Hashes zu berechnen. Ab **Node.js 17**
340
- mit **OpenSSL 3** ist DES-ECB standardmäßig deaktiviert. Der Aufruf wirft
341
- dann synchron:
355
+ ### `ERR_MODULE_NOT_FOUND` / „Cannot find package 'smb3-client'" / Worker startet nicht
342
356
 
343
- ```
344
- Error: error:0308010C:digital envelope routines::unsupported
345
- code: 'ERR_OSSL_EVP_UNSUPPORTED'
346
- ```
347
-
348
- Weil der Fehler synchron aus dem NTLM-Modul kommt, **tötet er den
349
- Saltcorn-Worker-Prozess**. Der davor sitzende Reverse-Proxy meldet dann
350
- `502 Bad Gateway` und die Antwort ist kein JSON — was auf der Config-Seite
351
- als "Antwort war kein JSON (HTTP 502)" erscheint.
352
-
353
- **Lösung:** Saltcorn mit dem Node-Flag `--openssl-legacy-provider` starten.
354
- Je nach Setup:
355
-
356
- **(a) Umgebungsvariable (empfohlen, wirkt auf jeden Node-Aufruf)**
357
+ Das Plugin lädt `smb3-client` (ESM) via dynamic `import()` — das npm-Paket
358
+ muss also im Plugin-Ordner installiert sein.
357
359
 
358
360
  ```bash
359
- export NODE_OPTIONS="--openssl-legacy-provider"
360
- saltcorn serve
361
+ cd /pfad/zu/saltcorn-samba
362
+ npm install
363
+ saltcorn restart # oder systemd/docker-Neustart
361
364
  ```
362
365
 
363
- **(b) systemd-Unit** (`/etc/systemd/system/saltcorn.service`)
366
+ Bei Installation über die Saltcorn-Plugin-UI (Source `npm` oder `github`)
367
+ führt Saltcorn `npm install` automatisch aus.
364
368
 
365
- ```ini
366
- [Service]
367
- Environment=NODE_OPTIONS=--openssl-legacy-provider
368
- ExecStart=/usr/bin/saltcorn serve
369
- ```
369
+ ### `STATUS_INVALID_PARAMETER` / „sign_algo_id=0" im Samba-Log
370
370
 
371
- Danach:
372
- ```bash
373
- sudo systemctl daemon-reload
374
- sudo systemctl restart saltcorn
375
- ```
371
+ Das war der Fehler in **0.3.x mit `@marsaud/smb2`**: moderne Samba-Server
372
+ (Ubuntu 22.04+ / Debian 12+ / RHEL 9+) verlangen **AES-CMAC-Signing**, das
373
+ die alte Library nicht konnte. **In 0.4.0 gelöst** — die neue `smb3-client`-
374
+ Library implementiert AES-CMAC. Falls die Meldung dennoch auftaucht:
376
375
 
377
- **(c) Docker / docker-compose**
376
+ - In der Plugin-Config **SMB-Signing = `required`** setzen (erzwingt
377
+ AES-CMAC schon beim Handshake).
378
+ - Auf dem Server sicherstellen, dass `server signing = mandatory` gesetzt
379
+ ist, dann in der Client-Config ebenfalls `required` wählen.
378
380
 
379
- ```yaml
380
- services:
381
- saltcorn:
382
- image: saltcorn/saltcorn:latest
383
- environment:
384
- NODE_OPTIONS: "--openssl-legacy-provider"
385
- ```
381
+ ### `ERR_OSSL_EVP_UNSUPPORTED` / `--openssl-legacy-provider`
386
382
 
387
- **(d) PM2**
383
+ **Betrifft 0.4.0 nicht mehr.** Die alte Abhängigkeit `@marsaud/smb2` nutzte
384
+ intern DES-ECB (aus dem transitiven `ntlm`-Paket), das ab OpenSSL 3
385
+ deaktiviert ist. `smb3-client` verwendet ausschließlich moderne Cipher
386
+ (HMAC-SHA256, AES-128-CMAC, AES-CCM/GCM, SHA-512-PreAuth-Integrity).
387
+ **Das `NODE_OPTIONS=--openssl-legacy-provider`-Flag darf und sollte in
388
+ 0.4.0 wieder entfernt werden.**
388
389
 
389
- ```bash
390
- pm2 delete saltcorn
391
- NODE_OPTIONS="--openssl-legacy-provider" pm2 start saltcorn -- serve
392
- pm2 save
393
- ```
390
+ ### Signing- und Verschlüsselungs-Modi
394
391
 
395
- **(e) Direkter Aufruf**
392
+ Beide Modi haben denselben Wertebereich:
396
393
 
397
- ```bash
398
- node --openssl-legacy-provider $(which saltcorn) serve
399
- ```
394
+ | Wert | Verhalten |
395
+ |---|---|
396
+ | `if-offered` **(Standard)** | Wird genutzt, wenn der Server es anbietet. Kompatibel mit alten und neuen Servern. |
397
+ | `required` | Wird zwingend verlangt. Verbindung schlägt fehl, wenn der Server nicht mitmacht. Empfohlen für Produktionsumgebungen. |
398
+ | `disabled` | Wird nicht angefordert. Nur für Legacy-Server oder isolierte LAN-Setups. Nicht mit Servern kombinieren, die Signing/Encryption erzwingen. |
400
399
 
401
- Nach dem Neustart im Config-Dialog erneut auf „Verbindung jetzt testen“
402
- klicken — die Fehlermeldung sollte weg sein.
400
+ Typische Fehlermeldungen und was zu tun ist:
403
401
 
404
- > **Hinweis zur Sicherheit:** `--openssl-legacy-provider` reaktiviert die
405
- > alten Cipher für den gesamten Node-Prozess. Das ist für NTLM-Auth gegen
406
- > Samba akzeptabel (der Cipher wird nur zur Hash-Berechnung genutzt, nicht
407
- > für den Transport), aber ein Grund, mittelfristig auf eine gepflegte
408
- > SMB-Client-Bibliothek zu wechseln siehe Roadmap.
402
+ | Symptom | Wahrscheinliche Ursache | Lösung |
403
+ |---|---|---|
404
+ | `bad signature` / `SIGNATURE_MISMATCH` | Uhrzeit-Drift zwischen Client und Server, oder falsche Credentials | Zeit synchronisieren (NTP), Passwort prüfen |
405
+ | `preauth integrity` | Man-in-the-Middle oder Netzwerkproblem | Netzwerkpfad prüfen (VPN, Proxy) |
406
+ | Verbindung schlägt bei `required` fehl | Server bietet Modus nicht an | Server konfigurieren oder Modus auf `if-offered` senken |
409
407
 
410
408
  ### Weitere häufige Fehler
411
409
 
412
410
  | Symptom | Ursache | Lösung |
413
411
  |---|---|---|
414
- | `getaddrinfo ENOTFOUND host:445` | Bis v0.3.9 wurde der Port in den UNC-Pfad geschrieben | Update auf **v0.3.10+** |
415
412
  | `ECONNREFUSED :445` | SMB-Dienst läuft nicht oder Firewall blockt Port 445 | `nc -vz <server> 445` vom Saltcorn-Host testen |
416
413
  | `ETIMEDOUT` | Kein Netzwerkpfad (VLAN/Docker-Bridge/VPN) | Vom Saltcorn-Container aus `ping` + `nc -vz` prüfen |
417
- | `STATUS_LOGON_FAILURE` | User/Passwort/Domain falsch | Zugangsdaten prüfen. Moderne Samba-Server erlauben keine leeren Passwörter |
418
- | `STATUS_BAD_NETWORK_NAME` | Share existiert nicht oder Schreibweise falsch | `smbclient -L //server -U user` zur Kontrolle |
414
+ | `LOGON_FAILURE` / `STATUS_LOGON_FAILURE` | User/Passwort/Domain falsch | Zugangsdaten prüfen. Moderne Samba-Server erlauben keine leeren Passwörter |
415
+ | `BAD_NETWORK_NAME` / `STATUS_BAD_NETWORK_NAME` | Share existiert nicht oder Schreibweise falsch | `smbclient -L //server -U user` zur Kontrolle |
419
416
  | Nur SMBv1 verfügbar | Server bietet SMB2/3 nicht an | In `smb.conf`: `min protocol = SMB2` |
420
417
 
421
418
  ---
package/index.js CHANGED
@@ -47,8 +47,6 @@ const {
47
47
  mimeFromName,
48
48
  sanitizeRelativePath,
49
49
  sanitizeFilename,
50
- checkLegacyCryptoAvailable,
51
- legacyCryptoErrorMessage,
52
50
  } = require("./smb-client");
53
51
  const treeView = require("./tree-view");
54
52
  const fileManagerView = require("./filemanager-view");
@@ -94,13 +92,15 @@ window.sambaTestConn = async function(btn) {
94
92
  if (!form) { out.innerHTML = '<div class="alert alert-danger">Formular nicht gefunden.</div>'; return; }
95
93
  function v(n){ var el = form.querySelector('[name="'+n+'"]'); return el ? el.value : ''; }
96
94
  var payload = {
97
- server: v('server'),
98
- share: v('share'),
99
- domain: v('domain'),
100
- username: v('username'),
101
- password: v('password'),
102
- base_path: v('base_path'),
103
- port: v('port')
95
+ server: v('server'),
96
+ share: v('share'),
97
+ domain: v('domain'),
98
+ username: v('username'),
99
+ password: v('password'),
100
+ base_path: v('base_path'),
101
+ port: v('port'),
102
+ signing_mode: v('signing_mode'),
103
+ encryption_mode: v('encryption_mode')
104
104
  };
105
105
  if (!payload.server || !payload.share) {
106
106
  out.innerHTML = '<div class="alert alert-warning">Bitte mindestens <b>Server</b> und <b>Share</b> ausfüllen.</div>';
@@ -289,6 +289,46 @@ const configuration_workflow = () =>
289
289
  type: "Integer",
290
290
  default: 445,
291
291
  }),
292
+ new Field({
293
+ name: "signing_mode",
294
+ label: "SMB-Signing",
295
+ sublabel:
296
+ "Wie streng jede Nachricht kryptografisch signiert wird (HMAC-SHA256 für SMB 2.x, AES-128-CMAC für SMB 3.x). " +
297
+ "<b>required</b>: Signing wird zwingend verlangt; Verbindung schlägt fehl, wenn der Server nicht signiert. " +
298
+ "<b>if-offered</b> (Standard): Signing wird genutzt, wenn der Server es anbietet, sonst weiter ohne. " +
299
+ "<b>disabled</b>: kein Signing (nur auswählen, wenn der Server Signing verweigert). " +
300
+ "Moderne Samba-Server verlangen häufig Signing → „required“ oder „if-offered“ setzen.",
301
+ type: "String",
302
+ required: true,
303
+ attributes: {
304
+ options: [
305
+ { value: "if-offered", label: "if-offered (Standard)" },
306
+ { value: "required", label: "required (strikt)" },
307
+ { value: "disabled", label: "disabled (aus)" },
308
+ ],
309
+ },
310
+ default: "if-offered",
311
+ }),
312
+ new Field({
313
+ name: "encryption_mode",
314
+ label: "SMB-Verschlüsselung",
315
+ sublabel:
316
+ "Verschlüsselung der Nutzdaten auf dem Draht (AES-128/256-CCM/GCM). " +
317
+ "<b>required</b>: Verbindung nur mit Verschlüsselung; scheitert, wenn der Server keine anbietet. " +
318
+ "<b>if-offered</b> (Standard): Verschlüsselung wird genutzt, wenn der Server sie anbietet. " +
319
+ "<b>disabled</b>: keine Verschlüsselung anfordern (nur für Legacy-Server oder LAN-Only-Setups). " +
320
+ "Shares, die serverseitig <code>SMB2_SHAREFLAG_ENCRYPT_DATA</code> tragen, erzwingen Verschlüsselung ohnehin.",
321
+ type: "String",
322
+ required: true,
323
+ attributes: {
324
+ options: [
325
+ { value: "if-offered", label: "if-offered (Standard)" },
326
+ { value: "required", label: "required (strikt)" },
327
+ { value: "disabled", label: "disabled (aus)" },
328
+ ],
329
+ },
330
+ default: "if-offered",
331
+ }),
292
332
  new Field({
293
333
  name: "_test_html",
294
334
  label: " ",
@@ -744,38 +784,13 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
744
784
  password: String(body.password || ""),
745
785
  base_path: String(body.base_path || "").trim(),
746
786
  port: Number(body.port) || 445,
787
+ signing_mode: String(body.signing_mode || "").trim() || undefined,
788
+ encryption_mode: String(body.encryption_mode || "").trim() || undefined,
747
789
  };
748
790
 
749
791
  if (!testCfg.server) return jsonError(res, 400, "Please enter a Server (hostname or IP).");
750
792
  if (!testCfg.share) return jsonError(res, 400, "Please enter a Share name.");
751
793
 
752
- // Vor jedem echten SMB-Aufruf: Node/OpenSSL-Legacy-Cipher-Check.
753
- // Falls DES-ECB nicht verfügbar ist (Node 17+ / OpenSSL 3 ohne
754
- // --openssl-legacy-provider), übergeben wir gar nicht erst an ntlm,
755
- // sonst crasht der Worker synchron und der Proxy meldet 502.
756
- const cc = checkLegacyCryptoAvailable();
757
- if (!cc.ok) {
758
- return res.status(200).json({
759
- ok: false,
760
- error: legacyCryptoErrorMessage(cc.message),
761
- code: "E_LEGACY_CRYPTO",
762
- hint:
763
- "Saltcorn mit --openssl-legacy-provider starten. " +
764
- "Am einfachsten: die Umgebungsvariable NODE_OPTIONS=--openssl-legacy-provider setzen und Saltcorn neu starten. " +
765
- "Details und Beispiele für systemd / Docker / PM2 stehen in der README des Plugins.",
766
- attempted: {
767
- server: testCfg.server,
768
- share: testCfg.share,
769
- port: testCfg.port,
770
- domain: testCfg.domain,
771
- username: testCfg.username || "(anonymous)",
772
- base_path: testCfg.base_path || "(share root)",
773
- },
774
- node_version: process.version,
775
- openssl_version: (process.versions && process.versions.openssl) || null,
776
- });
777
- }
778
-
779
794
  const started = Date.now();
780
795
  try {
781
796
  const listing = await withClient(testCfg, async (client) => {
@@ -827,8 +842,14 @@ code{background:#f4f4f4;padding:2px 6px;border-radius:3px;word-break:break-all}<
827
842
  hint = "The server may be offering only SMBv1 which this plugin does not support. Enable SMB2 / SMB3 on the Samba server (min protocol = SMB2 in smb.conf).";
828
843
  else if (m.includes("traversal") || m.includes("path"))
829
844
  hint = "The Base path could not be validated. It must be a relative sub-directory (e.g. 'projects/2026'), never start with / or \\, and must not contain '..'.";
830
- else if (code === "ERR_OSSL_EVP_UNSUPPORTED" || code === "E_LEGACY_CRYPTO" || m.includes("digital envelope routines") || m.includes("unsupported"))
831
- hint = "Node blockiert die Legacy-Cipher (DES-ECB), die @marsaud/smb2 für NTLM benötigt. Saltcorn mit NODE_OPTIONS=--openssl-legacy-provider neu starten. Details in der README.";
845
+ else if (m.includes("bad signature") || m.includes("sign_algo") || m.includes("signing"))
846
+ hint = "Signaturprüfung fehlgeschlagen. Setze in der Plugin-Config unter „SMB-Signing“ auf „if-offered“ oder prüfe auf dem Server, ob AES-CMAC unterstützt wird.";
847
+ else if (m.includes("pre-auth") || m.includes("preauth"))
848
+ hint = "Pre-Auth-Integrity fehlgeschlagen. Server und Client müssen SMB 3.1.1 sprechen. Auf Samba: 'server min protocol = SMB3_11' prüfen.";
849
+ else if (m.includes("encryption") || m.includes("encrypted"))
850
+ hint = "Verschlüsselungs-Verhandlung fehlgeschlagen. In der Plugin-Config „SMB-Verschlüsselung“ auf „if-offered“ setzen oder den Server so konfigurieren, dass er AES-GCM/CCM anbietet.";
851
+ else if (code === "ERR_OSSL_EVP_UNSUPPORTED" || m.includes("digital envelope routines"))
852
+ hint = "Node blockiert Legacy-Cipher. Ab v0.4.0 nutzt das Plugin smb3-client statt @marsaud/smb2 — diese Meldung sollte eigentlich nicht mehr auftreten. Bitte README-Abschnitt zu SMB3-Verbindung prüfen.";
832
853
 
833
854
  return res.status(200).json({
834
855
  ok: false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "saltcorn-samba",
3
- "version": "0.3.11",
4
- "description": "Saltcorn plugin: browse, upload, rename and delete files on a Samba/CIFS share. File-manager view, directory tree, inline PDF viewer, external-app open (smb://).",
3
+ "version": "0.4.0",
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",
@@ -31,10 +31,10 @@
31
31
  "url": "https://github.com/pv-host/saltcorn-samba/issues"
32
32
  },
33
33
  "engines": {
34
- "node": ">=18"
34
+ "node": ">=20"
35
35
  },
36
36
  "dependencies": {
37
- "@marsaud/smb2": "^0.18.0"
37
+ "smb3-client": "^0.2.0"
38
38
  },
39
39
  "files": [
40
40
  "index.js",
package/smb-client.js CHANGED
@@ -1,77 +1,53 @@
1
1
  /**
2
- * SMB client wrapper provides a small, connection-pooled interface
3
- * around @marsaud/smb2 with security-conscious path handling.
2
+ * SMB client wrapper provides a connection-scoped, `fs`-like interface
3
+ * around the `smb3-client` npm package (SMB 3.1.1 with AES-CMAC signing
4
+ * and optional AES-GCM encryption).
4
5
  *
5
- * All paths that come from the browser MUST be validated with
6
- * `sanitizeRelativePath` before being combined with the base_path.
6
+ * Design notes for v0.4.0:
7
+ *
8
+ * 1. Prior versions used `@marsaud/smb2` (SMB 2.0.2 / 2.1 only, tied to the
9
+ * unmaintained `ntlm` package that requires DES-ECB and dies on Node 17+
10
+ * with OpenSSL 3, and unable to satisfy `server signing = mandatory` on
11
+ * modern Samba servers because it lacks AES-CMAC signing).
12
+ *
13
+ * `smb3-client` speaks SMB 2.1 / 3.0 / 3.0.2 / 3.1.1, signs with either
14
+ * HMAC-SHA256 or AES-128-CMAC, does SHA-512 pre-auth integrity, has zero
15
+ * runtime dependencies, and no DES anywhere.
16
+ *
17
+ * 2. `smb3-client` is pure ESM. This plugin is CommonJS. We therefore load
18
+ * the module via a cached dynamic `import()` inside an async helper.
19
+ *
20
+ * 3. `smb3-client`'s path convention is "<share>/<sub>/<file>" — the share
21
+ * name is the FIRST segment of every path passed to `readFile`/`stat`/…
22
+ * We keep the plugin's external contract (callers still pass paths that
23
+ * are relative to the share root, e.g. "reports/2026/q1.xlsx"), and the
24
+ * wrapper prepends the share name and optional base_path.
25
+ *
26
+ * 4. All user-supplied path components MUST go through `sanitizeRelativePath`
27
+ * and `sanitizeFilename` before being handed to this wrapper. Those
28
+ * sanitizers are unchanged from previous versions (tests still cover them).
7
29
  */
8
30
 
9
31
  const path = require("path");
10
- const crypto = require("crypto");
11
-
12
- // @marsaud/smb2 is loaded lazily so the pure sanitizer helpers exported by
13
- // this module can be required (e.g. from unit tests) without needing the
14
- // native SMB dependency installed.
15
- let _SMB2 = null;
16
- function getSMB2() {
17
- if (_SMB2) return _SMB2;
18
- _SMB2 = require("@marsaud/smb2");
19
- return _SMB2;
20
- }
21
32
 
22
33
  // ---------------------------------------------------------------------------
23
- // OpenSSL 3 / Node 17+ compatibility check
34
+ // Dynamic ESM import cache for smb3-client
24
35
  // ---------------------------------------------------------------------------
25
36
  //
26
- // @marsaud/smb2 -> ntlm -> smbhash.js benutzt DES-ECB für die LM/NTLM-Hashes.
27
- // Node 17+ mit OpenSSL 3 blockiert DES-ECB standardmäßig; der Aufruf wirft
28
- // dann `error:0308010C:digital envelope routines::unsupported`
29
- // (ERR_OSSL_EVP_UNSUPPORTED). Der Fehler fliegt synchron und tötet den
30
- // Saltcorn-Worker der Reverse-Proxy meldet 502 Bad Gateway.
31
- //
32
- // Hier fangen wir das VOR dem eigentlichen SMB-Aufruf ab und liefern eine
33
- // verständliche Fehlermeldung mit Lösungsanleitung.
34
- let _cryptoOK = null;
35
- function checkLegacyCryptoAvailable() {
36
- if (_cryptoOK !== null) return _cryptoOK;
37
- try {
38
- // DES-ECB genau so aufrufen, wie es ntlm intern tut.
39
- // Wenn OpenSSL 3 ohne Legacy-Provider läuft, wirft das hier.
40
- const key = Buffer.alloc(8, 0);
41
- const c = crypto.createCipheriv("des-ecb", key, null);
42
- c.setAutoPadding(false);
43
- c.update(Buffer.alloc(8, 0));
44
- c.final();
45
- _cryptoOK = { ok: true };
46
- } catch (e) {
47
- _cryptoOK = {
48
- ok: false,
49
- code: e && e.code,
50
- message: (e && e.message) || String(e),
51
- };
52
- }
53
- return _cryptoOK;
54
- }
55
-
56
- /** Baut eine deutsche Erklärung für den Legacy-Provider-Fehler. */
57
- function legacyCryptoErrorMessage(detail) {
58
- return (
59
- "NTLM-Authentifizierung nicht möglich: Node.js blockiert die von " +
60
- "@marsaud/smb2 benötigten Legacy-Cipher (DES-ECB). Das ist ab Node 17 " +
61
- "mit OpenSSL 3 der Standard. " +
62
- "Lösung: Saltcorn mit dem Flag --openssl-legacy-provider starten. " +
63
- "Beispiele: " +
64
- "(1) direkt: `node --openssl-legacy-provider node_modules/.bin/saltcorn serve` " +
65
- "(2) per Umgebungsvariable: `NODE_OPTIONS=--openssl-legacy-provider saltcorn serve` " +
66
- "(3) systemd-Unit: `Environment=NODE_OPTIONS=--openssl-legacy-provider` " +
67
- "(4) Docker: im Compose-File `environment: NODE_OPTIONS: --openssl-legacy-provider`. " +
68
- "Danach Saltcorn neu starten. Details siehe README des Plugins. " +
69
- (detail ? "[Original: " + detail + "]" : "")
70
- );
37
+ // The `smb3-client` package is pure ESM (`"type": "module"`), so we cannot
38
+ // use CommonJS `require()`. Node supports `import()` in CommonJS as a dynamic
39
+ // expression, which returns a Promise. We cache the resolved module.
40
+ let _smb3Module = null;
41
+ async function getSmb3Client() {
42
+ if (_smb3Module) return _smb3Module;
43
+ // Use string concatenation so bundlers do not try to statically resolve
44
+ // this at build time.
45
+ _smb3Module = await import("smb3-client");
46
+ return _smb3Module;
71
47
  }
72
48
 
73
49
  // ---------------------------------------------------------------------------
74
- // Path helpers
50
+ // Path helpers (unchanged from previous versions — covered by unit tests)
75
51
  // ---------------------------------------------------------------------------
76
52
 
77
53
  /**
@@ -85,7 +61,6 @@ function sanitizeFilename(name) {
85
61
  if (typeof name !== "string") throw new Error("Filename must be a string");
86
62
  const trimmed = name.trim();
87
63
  if (!trimmed) throw new Error("Filename must not be empty");
88
- // reject leading/trailing whitespace on non-empty names (Windows problem)
89
64
  if (name !== trimmed)
90
65
  throw new Error("Filename must not start or end with whitespace");
91
66
  if (trimmed.length > 255) throw new Error("Filename too long");
@@ -93,12 +68,10 @@ function sanitizeFilename(name) {
93
68
  throw new Error("Filename must not be '.' or '..'");
94
69
  if (/[\\/]/.test(trimmed)) throw new Error("Filename must not contain slashes");
95
70
  if (/[\x00-\x1f]/.test(trimmed)) throw new Error("Filename must not contain control characters");
96
- // Reject characters SMB / Windows disallow in filenames
97
71
  if (/[<>:"|?*]/.test(trimmed))
98
72
  throw new Error('Filename must not contain any of: < > : " | ? *');
99
73
  if (trimmed.endsWith(".") || trimmed.endsWith(" "))
100
74
  throw new Error("Filename must not end with a dot or space");
101
- // Windows reserved device names (case-insensitive, with or without extension)
102
75
  const base = trimmed.split(".")[0].toUpperCase();
103
76
  const RESERVED = new Set([
104
77
  "CON", "PRN", "AUX", "NUL",
@@ -119,15 +92,11 @@ function sanitizeRelativePath(rel) {
119
92
  if (typeof rel !== "string") throw new Error("Path must be a string");
120
93
  if (rel.length > 4096) throw new Error("Path too long");
121
94
  if (rel.includes("\0")) throw new Error("Illegal NUL byte in path");
122
- // Convert backslashes to forward slashes first (do NOT collapse yet)
123
95
  let p = rel.replace(/\\/g, "/");
124
- // Reject Windows drive letters and UNC paths BEFORE collapsing slashes
125
96
  if (/^[a-zA-Z]:/.test(p)) throw new Error("Drive letters not allowed");
126
97
  if (p.startsWith("//")) throw new Error("UNC paths not allowed");
127
- // Now collapse multiple slashes and strip leading slash
128
98
  p = p.replace(/\/+/g, "/");
129
99
  if (p.startsWith("/")) p = p.slice(1);
130
- // Reject explicit traversal segments
131
100
  const parts = p.split("/").filter((s) => s !== "" && s !== ".");
132
101
  for (const seg of parts) {
133
102
  if (seg === "..") throw new Error("Path traversal not allowed");
@@ -135,176 +104,264 @@ function sanitizeRelativePath(rel) {
135
104
  return parts.join("/");
136
105
  }
137
106
 
138
- /** Convert a POSIX-style relative path into the SMB backslash form. */
107
+ /**
108
+ * Convert a POSIX-style relative path into the SMB backslash form.
109
+ * Kept for backwards compatibility — smb3-client itself uses forward
110
+ * slashes, but external callers (URL builders, logging) may still use
111
+ * this helper.
112
+ */
139
113
  function toSmbPath(rel) {
140
114
  return rel.replace(/\//g, "\\");
141
115
  }
142
116
 
117
+ // ---------------------------------------------------------------------------
118
+ // Host / port helpers
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Split a possibly-composite server field into { host, port }.
123
+ * Tolerant to `host:445` typed into the server field, `[::1]:445` IPv6, and
124
+ * plain hostnames or bare IPs. Never returns a host string that contains a
125
+ * colon-port suffix (that would break DNS lookup).
126
+ */
127
+ function parseHostPort(server, explicitPort) {
128
+ let host = String(server || "").trim();
129
+ let port;
130
+
131
+ const v6 = host.match(/^\[([^\]]+)\](?::(\d+))?$/);
132
+ if (v6) {
133
+ host = v6[1];
134
+ if (v6[2]) port = Number(v6[2]);
135
+ } else {
136
+ const colonCount = (host.match(/:/g) || []).length;
137
+ if (colonCount === 1) {
138
+ const [h, p] = host.split(":");
139
+ if (h && /^\d+$/.test(p)) {
140
+ host = h;
141
+ port = Number(p);
142
+ }
143
+ }
144
+ }
145
+
146
+ return {
147
+ host,
148
+ port: Number(explicitPort) || port || 445,
149
+ };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Config validation
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Normalise the security-mode fields to smb3-client's vocabulary. */
157
+ function normSecurityMode(v, fallback) {
158
+ const s = String(v || "").toLowerCase().trim();
159
+ if (s === "disabled" || s === "if-offered" || s === "required") return s;
160
+ return fallback;
161
+ }
162
+
143
163
  // ---------------------------------------------------------------------------
144
164
  // SMB client factory
145
165
  // ---------------------------------------------------------------------------
146
166
 
147
167
  /**
148
- * Build a fresh SMB2 client from the plugin configuration.
168
+ * Build and connect a fresh smb3-client `Client` from the plugin
169
+ * configuration. Returns a small wrapper object with `fs`-like methods that
170
+ * accept paths RELATIVE TO THE SHARE ROOT (with `base_path` automatically
171
+ * prepended). Always call `disconnect()` when done — or use `withClient()`.
149
172
  *
150
173
  * config = {
151
- * server: "192.168.1.10", // host or IP of the samba server
152
- * share: "documents", // share name (no slashes)
153
- * domain: "WORKGROUP", // optional
174
+ * server: "192.168.1.10", // host or IP
175
+ * share: "documents", // share name (no slashes)
176
+ * domain: "WORKGROUP", // optional
154
177
  * username: "reader",
155
178
  * password: "secret",
156
- * base_path:"", // optional subdirectory to lock into
157
- * port: 445, // optional
179
+ * base_path:"", // optional subdirectory lock
180
+ * port: 445, // optional
181
+ * signing_mode: "required", // "disabled" | "if-offered" | "required"
182
+ * encryption_mode: "if-offered", // ditto
158
183
  * }
159
- *
160
- * The returned object exposes: readdir(rel), stat(rel), readFile(rel),
161
- * createReadStream(rel), disconnect(), plus helpers `resolve(rel)` and
162
- * `basePath`.
163
184
  */
164
- function buildClient(config) {
185
+ async function buildClient(config) {
165
186
  if (!config) throw new Error("Samba plugin is not configured");
166
- const { server, share, domain, username, password, port } = config;
187
+ const { server, share, domain, username, password } = config;
167
188
  if (!server) throw new Error("Samba: server missing");
168
189
  if (!share) throw new Error("Samba: share missing");
169
- if (/[\\/]/.test(share)) throw new Error("Samba: share must not contain slashes");
170
-
171
- // WICHTIG: OpenSSL-Legacy-Check VOR jedem SMB2-Aufruf. Sonst crasht
172
- // ntlm/smbhash.js synchron und tötet den Worker (→ 502 am Proxy).
173
- const cc = checkLegacyCryptoAvailable();
174
- if (!cc.ok) {
175
- const err = new Error(legacyCryptoErrorMessage(cc.message));
176
- err.code = "E_LEGACY_CRYPTO";
177
- throw err;
178
- }
190
+ if (/[\\/]/.test(share))
191
+ throw new Error("Samba: share must not contain slashes");
179
192
 
180
- // Host und Port sauber trennen. Der Server-String darf NICHT im share-UNC-
181
- // Pfad landen, sonst versucht Node's DNS "host:port" als Hostnamen aufzulösen
182
- // (getaddrinfo ENOTFOUND "1.2.3.4:445"). @marsaud/smb2 nimmt den Port über
183
- // die separate `port`-Option entgegen; der share-String enthält nur den Host.
184
- //
185
- // Zusätzlich tolerant sein, falls jemand versehentlich "host:445" ins
186
- // Server-Feld getippt hat — dann Port dort rausziehen.
187
- let hostOnly = String(server).trim();
188
- let portFromServer;
189
- // IPv6-Adressen in eckigen Klammern zulassen: [::1]:445
190
- const v6 = hostOnly.match(/^\[([^\]]+)\](?::(\d+))?$/);
191
- if (v6) {
192
- hostOnly = v6[1];
193
- if (v6[2]) portFromServer = Number(v6[2]);
194
- } else {
195
- // Nur splitten, wenn genau EIN Doppelpunkt — sonst IPv6 ohne Klammern
196
- const colonCount = (hostOnly.match(/:/g) || []).length;
197
- if (colonCount === 1) {
198
- const [h, p] = hostOnly.split(":");
199
- if (h && /^\d+$/.test(p)) {
200
- hostOnly = h;
201
- portFromServer = Number(p);
202
- }
203
- }
204
- }
193
+ const { host, port } = parseHostPort(server, config.port);
194
+ const signing = normSecurityMode(config.signing_mode, "if-offered");
195
+ const encryption = normSecurityMode(config.encryption_mode, "if-offered");
205
196
 
206
- const effectivePort = Number(port) || portFromServer || 445;
207
- const shareStr = `\\\\${hostOnly}\\${share}`;
208
- const SMB2 = getSMB2();
209
- const smb = new SMB2({
210
- share: shareStr,
211
- domain: domain || "WORKGROUP",
212
- username: username || "guest",
197
+ const basePath = sanitizeRelativePath(config.base_path || "");
198
+ const shareName = String(share).trim();
199
+
200
+ const { Client } = await getSmb3Client();
201
+ const client = new Client({
202
+ host,
203
+ port,
204
+ domain: domain || "",
205
+ username: username || "",
213
206
  password: password || "",
214
- port: effectivePort,
215
- autoCloseTimeout: 10000,
207
+ connectTimeout: 10000,
208
+ requestTimeout: 30000,
209
+ signing,
210
+ encryption,
216
211
  });
217
212
 
218
- const basePath = sanitizeRelativePath(config.base_path || "");
213
+ await client.connect();
219
214
 
220
- /** Combine base + user-supplied relative into a validated SMB path. */
221
- function resolve(rel) {
215
+ /**
216
+ * Combine share + basePath + user-supplied relative into the full
217
+ * smb3-client-style path ("share/sub/dir/file.ext"), always sanitising
218
+ * the user input first.
219
+ */
220
+ function resolvePath(rel) {
222
221
  const safe = sanitizeRelativePath(rel);
223
- const combined = [basePath, safe].filter(Boolean).join("/");
224
- return toSmbPath(combined);
222
+ return [shareName, basePath, safe].filter(Boolean).join("/");
223
+ }
224
+
225
+ /**
226
+ * Convert a smb3-client Dirent + best-effort stat into the shape the
227
+ * rest of the plugin expects (`name`, `isDirectory` as function OR
228
+ * boolean, `size`, `mtime`, `birthtime`).
229
+ *
230
+ * We fetch stat data in parallel because smb3-client's readdir Dirent
231
+ * only carries name/isFile/isDirectory. For very large directories this
232
+ * would fan out into many stat calls — that's an acceptable trade-off
233
+ * for now; a future release can optimise by using SMB2_QUERY_DIRECTORY's
234
+ * FileBothDirectoryInformation output directly.
235
+ */
236
+ async function enrichEntry(dirent, parentFullPath) {
237
+ const isDir = !!dirent.isDirectory();
238
+ const fullPath = parentFullPath
239
+ ? parentFullPath + "/" + dirent.name
240
+ : dirent.name;
241
+ let size = 0;
242
+ let mtime;
243
+ let birthtime;
244
+ try {
245
+ const st = await client.stat(fullPath);
246
+ size = Number(st.size || 0);
247
+ mtime = st.mtime;
248
+ birthtime = st.ctime;
249
+ } catch (_) {
250
+ // Non-fatal; return whatever we already have.
251
+ }
252
+ return {
253
+ name: dirent.name,
254
+ isDirectory: isDir,
255
+ size,
256
+ mtime,
257
+ birthtime,
258
+ };
225
259
  }
226
260
 
227
261
  return {
262
+ /** Share name, exposed for URL building / logging. */
263
+ shareName,
264
+ /** Sanitised base path (may be ""). */
228
265
  basePath,
229
- resolve,
230
- readdir(rel) {
231
- return new Promise((res, rej) => {
232
- smb.readdir(resolve(rel), { stats: true }, (err, files) =>
233
- err ? rej(err) : res(files)
266
+ /** Underlying smb3-client Client — do not use unless you know why. */
267
+ _raw: client,
268
+
269
+ /**
270
+ * List a directory (share root by default). Returns an array of objects
271
+ * shaped like the legacy `@marsaud/smb2` output so index.js does not need
272
+ * to change: `{ name, isDirectory, size, mtime, birthtime }`.
273
+ */
274
+ async readdir(rel) {
275
+ const full = resolvePath(rel);
276
+ const dirents = await client.readdir(full, { withFileTypes: true });
277
+ // Parallel enrichment. Bounded to a reasonable concurrency to avoid
278
+ // saturating the SMB session on huge directories.
279
+ const CHUNK = 16;
280
+ const result = [];
281
+ for (let i = 0; i < dirents.length; i += CHUNK) {
282
+ const slice = dirents.slice(i, i + CHUNK);
283
+ const enriched = await Promise.all(
284
+ slice.map((d) => enrichEntry(d, full))
234
285
  );
235
- });
286
+ result.push(...enriched);
287
+ }
288
+ return result;
236
289
  },
237
- stat(rel) {
238
- return new Promise((res, rej) => {
239
- // marsaud-smb2 does not export a proper stat; emulate via readdir of parent
240
- const target = resolve(rel);
241
- const parent = target.includes("\\")
242
- ? target.slice(0, target.lastIndexOf("\\"))
243
- : "";
244
- const name = target.includes("\\")
245
- ? target.slice(target.lastIndexOf("\\") + 1)
246
- : target;
247
- smb.readdir(parent, { stats: true }, (err, files) => {
248
- if (err) return rej(err);
249
- const match = files.find((f) => f.name === name);
250
- if (!match) return rej(new Error("Not found"));
251
- res(match);
252
- });
253
- });
290
+
291
+ /** Stat a single file/directory (name-relative-to-share/basePath). */
292
+ async stat(rel) {
293
+ const full = resolvePath(rel);
294
+ const st = await client.stat(full);
295
+ // Return a shape compatible with the old readdir-emulated stat.
296
+ return {
297
+ name: full.split("/").pop(),
298
+ size: Number(st.size || 0),
299
+ mtime: st.mtime,
300
+ birthtime: st.ctime,
301
+ isDirectory: !!st.isDirectory,
302
+ isFile: !!st.isFile,
303
+ };
254
304
  },
255
- readFile(rel) {
256
- return new Promise((res, rej) => {
257
- smb.readFile(resolve(rel), (err, data) =>
258
- err ? rej(err) : res(data)
259
- );
260
- });
305
+
306
+ /** Read the full content of a file into a Buffer. */
307
+ async readFile(rel) {
308
+ return await client.readFile(resolvePath(rel));
261
309
  },
262
- writeFile(rel, data) {
263
- return new Promise((res, rej) => {
264
- smb.writeFile(resolve(rel), data, (err) =>
265
- err ? rej(err) : res()
266
- );
267
- });
310
+
311
+ /** Create or overwrite a file with the given Buffer / string. */
312
+ async writeFile(rel, data) {
313
+ return await client.writeFile(resolvePath(rel), data);
268
314
  },
269
- exists(rel) {
270
- return new Promise((res) => {
271
- smb.exists(resolve(rel), (err, ok) => res(err ? false : !!ok));
272
- });
315
+
316
+ /** Check whether a path exists (never throws). */
317
+ async exists(rel) {
318
+ try {
319
+ await client.stat(resolvePath(rel));
320
+ return true;
321
+ } catch (_) {
322
+ return false;
323
+ }
273
324
  },
274
- unlink(rel) {
275
- return new Promise((res, rej) => {
276
- smb.unlink(resolve(rel), (err) => (err ? rej(err) : res()));
277
- });
325
+
326
+ /** Delete a single file. */
327
+ async unlink(rel) {
328
+ return await client.rm(resolvePath(rel));
278
329
  },
279
- rmdir(rel) {
280
- return new Promise((res, rej) => {
281
- smb.rmdir(resolve(rel), (err) => (err ? rej(err) : res()));
282
- });
330
+
331
+ /** Remove an empty directory. */
332
+ async rmdir(rel) {
333
+ return await client.rmdir(resolvePath(rel));
283
334
  },
284
- mkdir(rel) {
285
- return new Promise((res, rej) => {
286
- smb.mkdir(resolve(rel), (err) => (err ? rej(err) : res()));
287
- });
335
+
336
+ /** Create a directory (non-recursive). */
337
+ async mkdir(rel) {
338
+ return await client.mkdir(resolvePath(rel));
288
339
  },
289
- rename(oldRel, newRel) {
290
- return new Promise((res, rej) => {
291
- smb.rename(resolve(oldRel), resolve(newRel), (err) =>
292
- err ? rej(err) : res()
293
- );
294
- });
340
+
341
+ /** Rename (or move within the same share). */
342
+ async rename(oldRel, newRel) {
343
+ return await client.rename(resolvePath(oldRel), resolvePath(newRel));
295
344
  },
345
+
346
+ /**
347
+ * Streaming read — returns a Node Readable. Note: unlike the callback-
348
+ * based old API this is synchronous (smb3-client's own signature).
349
+ */
296
350
  createReadStream(rel) {
297
- return new Promise((res, rej) => {
298
- smb.createReadStream(resolve(rel), (err, stream) =>
299
- err ? rej(err) : res(stream)
300
- );
301
- });
351
+ return client.createReadStream(resolvePath(rel));
352
+ },
353
+
354
+ /** Streaming write. */
355
+ createWriteStream(rel) {
356
+ return client.createWriteStream(resolvePath(rel));
302
357
  },
303
- disconnect() {
358
+
359
+ /** Tear down the SMB session and TCP socket. Always await this. */
360
+ async disconnect() {
304
361
  try {
305
- smb.disconnect();
362
+ await client.close();
306
363
  } catch (_) {
307
- // ignore
364
+ // ignore — best-effort teardown
308
365
  }
309
366
  },
310
367
  };
@@ -313,14 +370,15 @@ function buildClient(config) {
313
370
  /**
314
371
  * Execute a callback with a fresh SMB client and always disconnect afterwards.
315
372
  * Prefer this helper over building a long-lived client because SMB sessions
316
- * can time out and marsaud-smb2 does not handle reconnects well.
373
+ * time out on idle and smb3-client does not currently expose a reconnect
374
+ * primitive.
317
375
  */
318
376
  async function withClient(config, fn) {
319
- const client = buildClient(config);
377
+ const client = await buildClient(config);
320
378
  try {
321
379
  return await fn(client);
322
380
  } finally {
323
- client.disconnect();
381
+ await client.disconnect();
324
382
  }
325
383
  }
326
384
 
@@ -375,6 +433,4 @@ module.exports = {
375
433
  toSmbPath,
376
434
  toSmbUrl,
377
435
  mimeFromName,
378
- checkLegacyCryptoAvailable,
379
- legacyCryptoErrorMessage,
380
436
  };