package stacks import ( "context" "os" "path/filepath" "testing" ) func TestParseNumericUserSpec(t *testing.T) { tests := []struct { spec string uid, gid int wantUID bool wantGID bool }{ {"", 0, 0, true, true}, {"1000:1001", 1000, 1001, true, true}, {"1000", 1000, 0, true, false}, {"root:root", 0, 0, true, true}, {"app:app", 0, 0, false, false}, } for _, tt := range tests { u, g := parseNumericUserSpec(tt.spec) if (u != nil) != tt.wantUID || (g != nil) != tt.wantGID { t.Fatalf("%q presence got uid=%v gid=%v", tt.spec, u, g) } if u != nil && *u != tt.uid { t.Fatalf("%q uid=%d want %d", tt.spec, *u, tt.uid) } if g != nil && *g != tt.gid { t.Fatalf("%q gid=%d want %d", tt.spec, *g, tt.gid) } } } func TestReadHostAccountsAndAccessStatus(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\napp:x:1234:2345::/nonexistent:/usr/sbin/nologin\n"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\napp:x:2345:\n"), 0644); err != nil { t.Fatal(err) } users, groups, err := readHostAccounts(root) if err != nil { t.Fatal(err) } if u := accountByUID(users, 1234); u == nil || u.Name != "app" || u.GID != 2345 { t.Fatalf("unexpected user lookup: %#v", u) } if g := groupByGID(groups, 2345); g == nil || g.Name != "app" { t.Fatalf("unexpected group lookup: %#v", g) } s, err := New(t.TempDir()) if err != nil { t.Fatal(err) } s.ConfigureHostAccess(root, false) st := s.hostAccessStatus() if !st.Configured || !st.Available || st.ManagementEnabled { t.Fatalf("unexpected host access status: %#v", st) } } func TestCreateHostUserRequiresExplicitOptIn(t *testing.T) { s, err := New(t.TempDir()) if err != nil { t.Fatal(err) } if _, err := s.CreateHostUser(context.Background(), CreateHostUserInput{ContainerID: "demo", Username: "demo"}); err == nil { t.Fatal("expected host user creation to be disabled by default") } } func TestHostAccountNameValidation(t *testing.T) { for _, name := range []string{"dockwatch-app", "app_1", "_svc"} { if !validHostAccountName.MatchString(name) { t.Fatalf("valid account name rejected: %q", name) } } for _, name := range []string{"Root", "../root", "app user", "-root", ""} { if validHostAccountName.MatchString(name) { t.Fatalf("invalid account name accepted: %q", name) } } } func TestContainerIdentityUsesPID1AndMapsHostAccount(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "srv", "data"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\napp:x:1000:1000::/nonexistent:/usr/sbin/nologin\n"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\napp:x:1000:\n"), 0644); err != nil { t.Fatal(err) } bin := t.TempDir() docker := filepath.Join(bin, "docker") script := `#!/bin/sh set -eu if [ "$1" = "inspect" ]; then cat <<'JSON' [{"Id":"abc","Name":"/demo","Config":{"Image":"demo:latest","User":"","Env":["PUID=1000","PGID=1000"]},"State":{"Running":true},"HostConfig":{"Privileged":false,"CapAdd":[],"Devices":[]},"Mounts":[{"Type":"bind","Source":"/srv/data","Destination":"/data","RW":true}]}] JSON exit 0 fi if [ "$1" = "exec" ] && [ "$3" = "cat" ]; then printf 'Name:\tdemo\nUid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\n' exit 0 fi exit 2 ` if err := os.WriteFile(docker, []byte(script), 0755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) s, err := New(t.TempDir()) if err != nil { t.Fatal(err) } s.ConfigureHostAccess(root, false) r, err := s.ContainerIdentity(context.Background(), "demo") if err != nil { t.Fatal(err) } if r.EffectiveUID == nil || *r.EffectiveUID != 1000 || r.EffectiveGID == nil || *r.EffectiveGID != 1000 { t.Fatalf("unexpected identity: uid=%v gid=%v", r.EffectiveUID, r.EffectiveGID) } if r.RunsAsRoot == nil || *r.RunsAsRoot || r.RootAssessment != "non-root" { t.Fatalf("unexpected root assessment: %#v", r) } if r.HostUser == nil || r.HostUser.Name != "app" { t.Fatalf("expected host UID mapping, got %#v", r.HostUser) } if len(r.BindMounts) != 1 || r.BindMounts[0].Source != "/srv/data" { t.Fatalf("unexpected bind mounts: %#v", r.BindMounts) } } func TestNumericEnvPairPrefersPUIDPGID(t *testing.T) { cfg := map[string]any{"Env": []any{"USER_ID=2000", "GROUP_ID=2001", "PUID=1000", "PGID=1001"}} u, g, src := numericEnvPair(cfg) if u == nil || g == nil || *u != 1000 || *g != 1001 || src != "PUID/PGID environment" { t.Fatalf("unexpected bind identity: uid=%v gid=%v source=%q", u, g, src) } } func TestStaticWriteAccess(t *testing.T) { root := t.TempDir() p := filepath.Join(root, "data") if err := os.WriteFile(p, []byte("x"), 0640); err != nil { t.Fatal(err) } fi, err := os.Stat(p) if err != nil { t.Fatal(err) } u, g := fileOwnerIDs(fi) if u == nil || g == nil { t.Skip("platform does not expose Unix UID/GID") } ownerUID, ownerGID := *u, *g ok, _ := staticWriteAccess(fi, &ownerUID, &ownerGID, &ownerUID, &ownerGID, nil) if ok == nil || !*ok { t.Fatal("expected owner write access") } otherUID, otherGID := ownerUID+10000, ownerGID+10000 ok, _ = staticWriteAccess(fi, &ownerUID, &ownerGID, &otherUID, &otherGID, nil) if ok == nil || *ok { t.Fatal("expected other identity to lack write access") } } func TestSecureHostMappedPathRejectsSymlink(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "srv"), 0755); err != nil { t.Fatal(err) } if err := os.Symlink("/tmp", filepath.Join(root, "srv", "link")); err != nil { t.Skipf("symlink unavailable: %v", err) } if _, err := secureHostMappedPath(root, "/srv/link"); err == nil { t.Fatal("expected symlinked host path to be rejected") } } func TestParsePermissionMode(t *testing.T) { for in, want := range map[string]os.FileMode{"750": 0750, "0755": 0755, "000": 0} { got, err := parsePermissionMode(in) if err != nil || got != want { t.Fatalf("parsePermissionMode(%q)=%#o,%v want %#o", in, got, err, want) } } for _, in := range []string{"7777", "888", "75", "abc"} { if _, err := parsePermissionMode(in); err == nil { t.Fatalf("expected %q to be rejected", in) } } } func TestBindPermissionPreviewUsesBindIdentityAndOptIn(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "srv", "data"), 0750); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\n"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\n"), 0644); err != nil { t.Fatal(err) } bin := t.TempDir() docker := filepath.Join(bin, "docker") script := `#!/bin/sh set -eu if [ "$1" = "inspect" ]; then cat <<'JSON' [{"Id":"abc","Name":"/demo","Config":{"Image":"demo:latest","User":"","Env":["PUID=4242","PGID=4343"]},"State":{"Running":false},"HostConfig":{"Privileged":false,"CapAdd":[],"Devices":[],"UsernsMode":"host"},"Mounts":[{"Type":"bind","Source":"/srv/data","Destination":"/data","RW":true}]}] JSON exit 0 fi if [ "$1" = "info" ]; then printf '["name=seccomp"]' exit 0 fi exit 2 ` if err := os.WriteFile(docker, []byte(script), 0755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) svc, err := New(t.TempDir()) if err != nil { t.Fatal(err) } svc.ConfigureHostAccess(root, false) p, err := svc.BindPermissionPreview(context.Background(), BindPermissionPreviewInput{ContainerID: "demo", Destination: "/data"}) if err != nil { t.Fatal(err) } if p.ExpectedUID == nil || *p.ExpectedUID != 4242 || p.ExpectedGID == nil || *p.ExpectedGID != 4343 { t.Fatalf("unexpected expected bind identity: %#v", p) } if p.CanRepair || p.BlockedReason == "" { t.Fatalf("repair should be blocked without explicit opt-in: %#v", p) } svc.ConfigureHostPermissionManagement(true) p, err = svc.BindPermissionPreview(context.Background(), BindPermissionPreviewInput{ContainerID: "demo", Destination: "/data"}) if err != nil { t.Fatal(err) } if !p.CanRepair { t.Fatalf("expected repair to be enabled after opt-in: %#v", p) } if p.Source != "/srv/data" || p.Destination != "/data" { t.Fatalf("unexpected mount: %#v", p) } }