package appexport import ( "encoding/json" "strings" "testing" ) // Regression test for [CTRL-001] (path traversal on .fab import). Originated as // a failing audit test (audit/2026-06-13-deep-sweep); now a permanent guard. // UnmarshalManifest must REJECT any manifest whose AppName / HDDSubdirs / // VolumeNames contain a path-traversal or separator, and ACCEPT legitimate // single-segment names. Do NOT weaken these assertions. func mustManifestJSON(t *testing.T, m Manifest) []byte { t.Helper() b, err := json.Marshal(m) if err != nil { t.Fatalf("marshal: %v", err) } return b } func TestUnmarshalManifestRejectsTraversal(t *testing.T) { cases := []struct { name string m Manifest }{ {"appname-parent", Manifest{Version: 1, AppName: "../evil"}}, {"appname-deep", Manifest{Version: 1, AppName: "../../etc/cron.d/x"}}, {"appname-absolute", Manifest{Version: 1, AppName: "/etc/cron.d/x"}}, {"appname-dotdot", Manifest{Version: 1, AppName: ".."}}, {"appname-empty", Manifest{Version: 1, AppName: ""}}, {"appname-backslash", Manifest{Version: 1, AppName: `..\evil`}}, {"hdd-subdir-escape", Manifest{Version: 1, AppName: "romm", HDDSubdirs: []string{"../../mnt"}}}, {"volume-escape", Manifest{Version: 1, AppName: "romm", VolumeNames: []string{"../../var/lib"}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { _, err := UnmarshalManifest(mustManifestJSON(t, tc.m)) if err == nil { t.Fatalf("CTRL-001 regression: UnmarshalManifest accepted a traversal manifest %+v; expected rejection", tc.m) } }) } } func TestUnmarshalManifestAcceptsLegitNames(t *testing.T) { m := Manifest{ Version: 1, AppName: "paperless-ngx", HDDSubdirs: []string{"felhom-usb", "romm"}, VolumeNames: []string{"adventurelog_postgres_data", "romm_redis-data"}, ConfigFiles: []string{".felhom.yml", "docker-compose.yml", "app.yaml"}, // dotfiles must NOT be rejected } got, err := UnmarshalManifest(mustManifestJSON(t, m)) if err != nil { t.Fatalf("CTRL-001 regression: UnmarshalManifest rejected a legitimate manifest: %v", err) } if got.AppName != "paperless-ngx" { t.Fatalf("AppName round-trip mismatch: %q", got.AppName) } } func TestValidateSegment(t *testing.T) { good := []string{"romm", "paperless-ngx", "adventurelog_postgres_data", "felhom-usb", "a", "App1.2_3-4"} for _, s := range good { if err := ValidateSegment("x", s); err != nil { t.Errorf("ValidateSegment(%q) = %v; want nil", s, err) } } bad := []string{"", ".", "..", "../x", "a/b", `a\b`, "/abs", ".hidden", "-leadingdash", "a/../b"} for _, s := range bad { if err := ValidateSegment("x", s); err == nil { t.Errorf("ValidateSegment(%q) = nil; want rejection", s) } } // Sanity: a rejected value's message names the kind, for operator clarity. if err := ValidateSegment("app_name", "../x"); err == nil || !strings.Contains(err.Error(), "app_name") { t.Errorf("expected error mentioning app_name, got %v", err) } }