package shares import ( "context" "errors" "path/filepath" "strings" "testing" ) func sample() Config { c := Defaults() c.SMB.Shares = []SMBShare{ {Name: "documents", Path: "/nas_pool_1_8_gb/documents", Comment: "Docs", Guest: true, Browseable: true, HideSysFiles: true}, {Name: "private", Path: "/nas_pool_4_gb/backups", ReadOnly: true, Users: []string{"alice", "bob"}, Browseable: false}, } c.NFS.Exports = []NFSExport{ {Path: "/nas_pool_1_8_gb/documents", Clients: []string{"192.168.101.0/24", "pc.lan"}, Squash: "root"}, {Path: "/tmp_pool/downloads", Clients: []string{"*"}, ReadOnly: true, Squash: "all", Async: true}, } return c } func TestRenderSMB(t *testing.T) { out := RenderSMB(sample(), "ansible") for _, want := range []string{ "[global]", "workgroup = WORKGROUP", "map to guest = Bad User", "server min protocol = SMB2", "[documents]", "path = /nas_pool_1_8_gb/documents", "comment = Docs", "guest ok = yes", "read only = no", "browseable = yes", "veto files = /._*/.DS_Store/Thumbs.db/desktop.ini/", "[private]", "guest ok = no", "valid users = alice bob", "read only = yes", "browseable = no", "force user = ansible", "force group = ansible", "create mask = 0664", } { if !strings.Contains(out, want) { t.Errorf("smb.conf missing %q\n%s", want, out) } } if strings.Count(out, "valid users") != 1 { t.Errorf("guest share must not list valid users") } if strings.Count(out, "\n veto files = ") != 1 { t.Errorf("veto files only where requested") } } func TestRenderExports(t *testing.T) { out := RenderExports(sample(), "1000", "1000") want := []string{ `"/nas_pool_1_8_gb/documents" 192.168.101.0/24(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check) pc.lan(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`, `"/tmp_pool/downloads" *(ro,async,all_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`, } for _, w := range want { if !strings.Contains(out, w) { t.Errorf("exports missing %q\n%s", w, out) } } none := Config{NFS: NFSConfig{Exports: []NFSExport{{Path: "/x", Clients: []string{"h"}, Squash: "none"}}}} if !strings.Contains(RenderExports(none, "1", "1"), "no_root_squash") || strings.Contains(RenderExports(none, "1", "1"), "anonuid") { t.Errorf("squash=none rendering wrong") } } func TestValidation(t *testing.T) { for _, n := range []string{"docs", "My.Share-1", "a"} { if err := ValidateShareName(n); err != nil { t.Errorf("%q rejected: %v", n, err) } } for _, n := range []string{"", "global", "Homes", "print$", "-x", "a b", "a/b", strings.Repeat("x", 81), "a[b]"} { if ValidateShareName(n) == nil { t.Errorf("%q accepted", n) } } for _, p := range []string{"/nas/x", "/tmp_pool"} { if err := ValidatePath(p); err != nil { t.Errorf("%q rejected: %v", p, err) } } for _, p := range []string{"", "/", "nas/x", "/nas/../etc", "/nas/", "/nas/x\"y", "/nas/x\n"} { if ValidatePath(p) == nil { t.Errorf("%q accepted", p) } } if !UnderRoots("/nas_pool/docs/sub", []string{"/tmp_pool", "/nas_pool"}) || UnderRoots("/nas_pool2", []string{"/nas_pool"}) || UnderRoots("/etc", []string{"/"}) { t.Errorf("UnderRoots") } for _, c := range []string{"*", "192.168.1.5", "192.168.1.0/24", "pc.lan", "*.lan", "fe80::1"} { if err := ValidateClient(c); err != nil { t.Errorf("%q rejected: %v", c, err) } } for _, c := range []string{"", "192.168.1.0/33", "a b", "(rw)", "host,other"} { if ValidateClient(c) == nil { t.Errorf("%q accepted", c) } } for _, u := range []string{"alice", "bob_2", "x-y"} { if err := ValidateUserName(u); err != nil { t.Errorf("%q rejected: %v", u, err) } } for _, u := range []string{"", "Alice", "root", "nobody", "1abc", "a b"} { if ValidateUserName(u) == nil { t.Errorf("%q accepted", u) } } c := sample() if err := c.Validate(); err != nil { t.Fatalf("sample invalid: %v", err) } c.SMB.Shares = append(c.SMB.Shares, SMBShare{Name: "documents", Path: "/x"}) if c.Validate() == nil { t.Errorf("duplicate share accepted") } c = sample() c.SMB.Shares[1].Users = nil if c.Validate() == nil { t.Errorf("non-guest share without users accepted") } c = sample() c.NFS.Exports[0].Squash = "maybe" if c.Validate() == nil { t.Errorf("bad squash accepted") } if got := SplitList(" a, b;c\n d "); strings.Join(got, "|") != "a|b|c|d" { t.Errorf("SplitList = %v", got) } } func TestStoreRoundTrip(t *testing.T) { p := filepath.Join(t.TempDir(), "shares.json") st, err := Load(p) if err != nil { t.Fatal(err) } if err := st.Save(sample()); err != nil { t.Fatal(err) } st2, err := Load(p) if err != nil { t.Fatal(err) } c := st2.Get() if len(c.SMB.Shares) != 2 || len(c.NFS.Exports) != 2 || c.FindSMB("private") != 1 || c.FindNFS("/tmp_pool/downloads") != 1 { t.Errorf("round trip: %+v", c) } // Get returns a copy c.SMB.Shares[0].Name = "changed" if st2.Get().SMB.Shares[0].Name == "changed" { t.Errorf("Get leaked internal state") } } // fake records host commands; stdin is captured for writes. type fake struct { calls []string stdin map[string]string fail map[string]string reply map[string]string lookup map[string]bool } func newFake() *fake { return &fake{stdin: map[string]string{}, fail: map[string]string{}, reply: map[string]string{}, lookup: map[string]bool{"smbd": true, "exportfs": true}} } func (f *fake) run(_ context.Context, stdin, name string, args ...string) (string, error) { c := name + " " + strings.Join(args, " ") f.calls = append(f.calls, c) if stdin != "" { f.stdin[c] = stdin } for k, v := range f.fail { if strings.HasPrefix(c, k) { return v, errors.New(v) } } for k, v := range f.reply { if strings.HasPrefix(c, k) { return v, nil } } return "", nil } func (f *fake) lookPath(bin string) (string, error) { if f.lookup[bin] { return "/usr/sbin/" + bin, nil } return "", errors.New("not found") } func (f *fake) has(prefix string) bool { for _, c := range f.calls { if strings.HasPrefix(c, prefix) { return true } } return false } func (f *fake) index(prefix string) int { for i, c := range f.calls { if strings.HasPrefix(c, prefix) { return i } } return -1 } func newManager(f *fake) *Manager { m := New(f.run) m.LookPath = f.lookPath m.Owner, m.UID, m.GID = "ansible", "1000", "1000" return m } func TestApplyOrder(t *testing.T) { f := newFake() m := newManager(f) if err := m.Apply(sample()); err != nil { t.Fatalf("apply: %v\n%v", err, f.calls) } // smb.conf written through sh with the rendered content on stdin wi := f.index("sh -c") if wi < 0 || !strings.Contains(f.calls[wi], DefaultSMBConf) { t.Fatalf("smb.conf not written: %v", f.calls) } if !strings.Contains(f.stdin[f.calls[wi]], "[documents]") { t.Errorf("stdin lacks rendered config") } ti, si, ri := f.index("testparm -s"), f.index("systemctl enable --now smbd nmbd"), f.index("smbcontrol all reload-config") if !(wi < ti && ti < si && si < ri) { t.Errorf("SMB order wrong: %v", f.calls) } ni, ei := f.index("systemctl enable --now nfs-server"), f.index("exportfs -ra") if ni < 0 || ei < ni { t.Errorf("NFS order wrong: %v", f.calls) } // exports written found := false for c, in := range f.stdin { if strings.Contains(c, DefaultExports) && strings.Contains(in, "crossmnt") { found = true } } if !found { t.Errorf("exports not written: %v", f.stdin) } } func TestApplyTestparmFailureRestores(t *testing.T) { f := newFake() f.fail["testparm"] = "Error loading services" m := newManager(f) err := m.Apply(sample()) if err == nil || !strings.Contains(err.Error(), "Error loading services") { t.Fatalf("expected testparm error, got %v", err) } if f.has("smbcontrol") || f.has("systemctl enable --now smbd") { t.Errorf("smbd touched after failed testparm: %v", f.calls) } restored := false for _, c := range f.calls { if strings.HasPrefix(c, "sh -c") && strings.Contains(c, ".bak") && strings.Contains(c, "mv -f") && strings.Contains(c, DefaultSMBConf) && !strings.Contains(c, "cat >") { restored = true } } if !restored { t.Errorf("backup not restored: %v", f.calls) } // NFS is still applied independently if !f.has("exportfs -ra") { t.Errorf("NFS skipped after SMB failure") } } func TestApplyDisablesWhenEmpty(t *testing.T) { f := newFake() m := newManager(f) c := Defaults() // no shares, no exports if err := m.Apply(c); err != nil { t.Fatal(err) } if !f.has("systemctl disable --now smbd nmbd") || !f.has("systemctl disable --now nfs-server") { t.Errorf("services not disabled: %v", f.calls) } if f.has("smbcontrol") || f.has("exportfs") { t.Errorf("reload attempted with nothing to serve: %v", f.calls) } } func TestApplyNotInstalled(t *testing.T) { f := newFake() f.lookup = map[string]bool{} m := newManager(f) if err := m.Apply(sample()); err != nil || len(f.calls) != 0 { t.Errorf("nothing should run without samba/nfs: %v %v", err, f.calls) } } func TestUsers(t *testing.T) { f := newFake() f.reply["pdbedit -L"] = "alice:1001:\nbob:1002:Bob B\n" f.fail["getent passwd carol"] = "" f.reply["getent passwd bob"] = "bob:x:1002:1000::/nonexistent:/usr/sbin/nologin" m := newManager(f) users, err := m.ListUsers() if err != nil || strings.Join(users, ",") != "alice,bob" { t.Fatalf("list: %v %v", users, err) } if err := m.SetPassword("carol", "s3cret"); err != nil { t.Fatal(err) } if !f.has("useradd -M -N -g 1000 -s /usr/sbin/nologin") { t.Errorf("unix user not created: %v", f.calls) } if in := f.stdin["smbpasswd -s -a carol"]; in != "s3cret\ns3cret\n" { t.Errorf("smbpasswd stdin = %q", in) } if err := m.SetPassword("Carol", "x"); err == nil { t.Errorf("bad user name accepted") } if err := m.SetPassword("carol", "ab"); err == nil { t.Errorf("short password accepted") } if err := m.DeleteUser("bob"); err != nil { t.Fatal(err) } if !f.has("smbpasswd -x bob") || !f.has("userdel bob") { t.Errorf("delete: %v", f.calls) } // the data owner keeps the unix account f.reply["getent passwd ansible"] = "ansible:x:1000:1000::/home/ansible:/usr/sbin/nologin" _ = m.DeleteUser("ansible") if f.has("userdel ansible") { t.Errorf("owner account deleted") } }