feat(samba): settings registry + smb.conf/compose renderers (R-7 slice 1, Part 1)

SMBSettings + SMBShare registry in settings (password never stored — only UserSet);
NetBIOS-safe name validation. Pure infra renderers: RenderSambaConfig (hardened
global block: SMB2 floor, bind interfaces only=lo eth0, disable netbios=no, force
user block) + RenderSambaCompose (network_mode host, pinned image, :ro bind for
read-only shares, passdb volume). Exact smb.conf golden + CRUD/validation tests.
This commit is contained in:
2026-07-18 11:21:05 +02:00
parent f42f3e0e08
commit b0c5ef4823
6 changed files with 568 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
package settings
import (
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func newSMBTestSettings(t *testing.T) (*Settings, string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
s, err := Load(path, log.New(os.Stderr, "", 0))
if err != nil {
t.Fatalf("Load: %v", err)
}
return s, path
}
func TestValidateSMBServerName(t *testing.T) {
good := []string{"FELHOM", "Otthon-1", "box_2", "A", "sixteencharnam"} // last = 14 chars
for _, n := range good {
if err := ValidateSMBServerName(n); err != nil {
t.Errorf("%q should be valid: %v", n, err)
}
}
bad := []string{"", " ", "sixteencharname1", "has space", "bad/slash", "-lead", "trail-", "dot.name", "back\\slash"}
for _, n := range bad {
if err := ValidateSMBServerName(n); err == nil {
t.Errorf("%q should be invalid", n)
}
}
}
func TestValidateSMBShareName(t *testing.T) {
good := []string{"dokumentumok", "filmek", "csalad_kepek", "A1"}
for _, n := range good {
if err := ValidateSMBShareName(n); err != nil {
t.Errorf("%q should be valid: %v", n, err)
}
}
// Scenario C: "../x", >15-char, and invalid NetBIOS charset must all refuse.
bad := []string{"", "../x", "a/b", "a\\b", "file.txt", "way-too-long-nam", "has space", "-x", "x-"}
for _, n := range bad {
if err := ValidateSMBShareName(n); err == nil {
t.Errorf("%q should be invalid", n)
}
}
}
func TestSMBShareCRUD(t *testing.T) {
s, path := newSMBTestSettings(t)
sh := SMBShare{Name: "dokumentumok", Path: "/mnt/felhom-drives/scratch1/shares/dokumentumok", Offsite: true}
if err := s.AddSMBShare(sh); err != nil {
t.Fatalf("AddSMBShare: %v", err)
}
got := s.GetSMBShares()
if len(got) != 1 || got[0].Name != "dokumentumok" || !got[0].Offsite {
t.Fatalf("share not stored as expected: %+v", got)
}
if got[0].CreatedAt == "" {
t.Error("CreatedAt not stamped on add")
}
// Case-insensitive collision is refused AND does not mutate the registry (Scenario C non-effect).
if err := s.AddSMBShare(SMBShare{Name: "DOKUMENTUMOK", Path: "/other"}); err == nil {
t.Error("case-insensitive name collision should be refused")
}
if len(s.GetSMBShares()) != 1 {
t.Error("refused collision must not mutate the registry")
}
// Offsite (Felhőmentés) toggle persists.
if err := s.SetSMBShareOffsite("dokumentumok", false); err != nil {
t.Fatalf("SetSMBShareOffsite: %v", err)
}
if s.GetSMBShares()[0].Offsite {
t.Error("offsite toggle did not persist")
}
// Persistence across reload.
s2, err := Load(path, log.New(os.Stderr, "", 0))
if err != nil {
t.Fatalf("reload: %v", err)
}
if r := s2.GetSMBShares(); len(r) != 1 || r[0].Name != "dokumentumok" {
t.Errorf("share did not persist across reload: %+v", r)
}
// Remove is config-only + errors on a missing name.
if err := s.RemoveSMBShare("dokumentumok"); err != nil {
t.Fatalf("RemoveSMBShare: %v", err)
}
if len(s.GetSMBShares()) != 0 {
t.Error("share not removed")
}
if err := s.RemoveSMBShare("nope"); err == nil {
t.Error("removing a missing share should error")
}
}
func TestSMBSettingsDefaultsAndNoPassword(t *testing.T) {
s, path := newSMBTestSettings(t)
// Unconfigured → disabled with the default server name (never nil).
def := s.GetSMBSettings()
if def.Enabled || def.ServerName != DefaultSMBServerName {
t.Errorf("default SMB settings wrong: %+v", def)
}
if err := s.SetSMBEnabled(true); err != nil {
t.Fatal(err)
}
if err := s.SetSMBServerName("OTTHON"); err != nil {
t.Fatal(err)
}
if err := s.SetSMBUserSet(true); err != nil {
t.Fatal(err)
}
cur := s.GetSMBSettings()
if !cur.Enabled || cur.ServerName != "OTTHON" || !cur.UserSet {
t.Errorf("SMB settings not stored: %+v", cur)
}
// The household SMB password must NEVER be persisted (rule 4). The struct has no field for it;
// prove the on-disk file carries only the UserSet flag, no plaintext secret.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "password") && strings.Contains(string(raw), "smb") {
// there is a claim_code_hash etc.; assert no *smb* password key specifically
if strings.Contains(string(raw), "smb_password") || strings.Contains(string(raw), "\"password\": \"") {
t.Errorf("settings.json appears to store an SMB password:\n%s", raw)
}
}
}