package stacks import ( "os" "path/filepath" "reflect" "strings" "testing" ) // C6B-F1 (v0.130.0) — ExportDataMounts is the .fab export's mount discovery: the ${HDD_PATH} // binds UNIONed with the ${USERDATA_PATH} root. These tests pin the union, the containment // dedupe, and — as the red-proof's contrast — that the old ${HDD_PATH}-only scanner alone // finds NOTHING for the standard media-app compose shape (the exact hollow-bundle cause). func writeCompose(t *testing.T, body string) string { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "docker-compose.yml") if err := os.WriteFile(p, []byte(body), 0644); err != nil { t.Fatal(err) } return p } // the sonarr shape: named volume + ${USERDATA_PATH} binds, NO direct ${HDD_PATH} bind. const sonarrShapeCompose = `services: sonarr: image: lscr.io/linuxserver/sonarr:4.0.13 volumes: - sonarr_config:/config - ${USERDATA_PATH}/media/tv:/media/tv - ${USERDATA_PATH}/downloads:/downloads networks: - traefik-public volumes: sonarr_config: ` func TestExportDataMounts_UserdataConvention(t *testing.T) { compose := writeCompose(t, sonarrShapeCompose) hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "sonarr") // RED-PROOF CONTRAST (C6B-F1 cause 2): the pre-fix scanner alone finds ZERO mounts for // this compose — this is exactly why 12/13 needs_hdd apps exported hollow bundles. if old := ParseComposeHDDMounts(compose, hdd); len(old) != 0 { t.Fatalf("precondition drifted: ParseComposeHDDMounts found %v — the compose shape no longer reproduces C6B-F1", old) } got := ExportDataMounts(compose, hdd, hdd) want := []string{filepath.Join(hdd, "userdata")} if !reflect.DeepEqual(got, want) { t.Fatalf("ExportDataMounts = %v, want the userdata ROOT %v", got, want) } // The basename MUST be "userdata" — a direct child of HDD_PATH — so the import side's // basename→/ fallback places the tar correctly without import changes. if filepath.Base(got[0]) != "userdata" { t.Fatalf("userdata mount bases to %q — the restore mapping requires \"userdata\"", filepath.Base(got[0])) } } func TestExportDataMounts_HDDDirectAppUnchanged(t *testing.T) { // The 1/13 app that worked pre-fix: direct ${HDD_PATH} binds, no userdata refs. compose := writeCompose(t, `services: app: image: x volumes: - ${HDD_PATH}/data:/data - ${HDD_PATH}/incoming:/incoming `) hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app") got := ExportDataMounts(compose, hdd, hdd) want := []string{filepath.Join(hdd, "data"), filepath.Join(hdd, "incoming")} if !reflect.DeepEqual(got, want) { t.Fatalf("ExportDataMounts = %v, want unchanged HDD mounts %v (regression: the one working app must keep working)", got, want) } } func TestExportDataMounts_MixedBindsUnion(t *testing.T) { compose := writeCompose(t, `services: app: image: x volumes: - ${HDD_PATH}/direct:/direct - ${USERDATA_PATH}/media:/media `) hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app") got := ExportDataMounts(compose, hdd, hdd) want := []string{filepath.Join(hdd, "direct"), filepath.Join(hdd, "userdata")} if !reflect.DeepEqual(got, want) { t.Fatalf("ExportDataMounts = %v, want the union %v", got, want) } } func TestExportDataMounts_HDDRootCoversUserdata(t *testing.T) { // An app binding ${HDD_PATH} itself already captures the userdata subtree — the root // must NOT be added again (double-tar). compose := writeCompose(t, `services: app: image: x volumes: - ${HDD_PATH}:/all - ${USERDATA_PATH}/media:/media `) hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app") got := ExportDataMounts(compose, hdd, hdd) want := []string{hdd} if !reflect.DeepEqual(got, want) { t.Fatalf("ExportDataMounts = %v, want just the covering HDD root %v", got, want) } } func TestExportDataMounts_LiteralUserdataBindDeduped(t *testing.T) { // A literal ${HDD_PATH}/userdata/foo bind is INSIDE the userdata root — keeping it would // double-tar and basename-collide with the root tar. compose := writeCompose(t, `services: app: image: x volumes: - ${HDD_PATH}/userdata/foo:/foo - ${USERDATA_PATH}/bar:/bar `) hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app") got := ExportDataMounts(compose, hdd, hdd) want := []string{filepath.Join(hdd, "userdata")} if !reflect.DeepEqual(got, want) { t.Fatalf("ExportDataMounts = %v, want only the userdata root %v", got, want) } } func TestExportDataMounts_EmptyHDDPath(t *testing.T) { compose := writeCompose(t, sonarrShapeCompose) if got := ExportDataMounts(compose, "", ""); got != nil { t.Fatalf("ExportDataMounts with empty HDD_PATH = %v, want nil", got) } } // R-203 / Scenario E — the export-mount resolver on BOTH drive kinds, and what it does NOT return. // // This function lives in delete.go, which reads as a destructive path; it is not. Its only production // caller is the .fab export adapter. The assertions below therefore prove the NEGATIVE that matters // for an export: no path outside the app's own data roots is ever emitted. func TestExportDataMounts_BothDriveKinds(t *testing.T) { const sys = "/mnt/sys_drive" compose := writeCompose(t, `services: app: volumes: - ${USERDATA_PATH}/media/books:/books - ${HDD_PATH}/appdata/app:/data `) cases := []struct { name, hdd, nsRoot, wantUD string }{ {"enrolled drive", "/mnt/felhom-usb", "/mnt/felhom-usb", "/mnt/felhom-usb/userdata"}, {"system drive", sys, sys + "/felhom-data", sys + "/felhom-data/userdata"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := ExportDataMounts(compose, tc.hdd, tc.nsRoot) var sawUD bool for _, m := range got { if m == tc.wantUD { sawUD = true } // THE NEGATIVE: nothing outside the app's own roots, on either kind. if !strings.HasPrefix(m, tc.hdd) && !strings.HasPrefix(m, tc.nsRoot) { t.Errorf("emitted %q, which is outside both %q and %q", m, tc.hdd, tc.nsRoot) } for _, forbidden := range []string{"/etc", "/opt/docker/stacks", "/var/lib/docker", "/root"} { if strings.HasPrefix(m, forbidden) { t.Errorf("emitted a path outside the app's data: %q", m) } } } if !sawUD { t.Errorf("userdata root %q not emitted; got %v", tc.wantUD, got) } }) } } // An empty nsRoot falls back to hddPath — the pre-R-203 shape — so a caller that has not been // updated keeps working on enrolled drives rather than emitting an empty-rooted path. func TestExportDataMounts_EmptyNamespaceRootFallsBack(t *testing.T) { compose := writeCompose(t, `services: app: volumes: - ${USERDATA_PATH}/x:/x `) withNS := ExportDataMounts(compose, "/mnt/felhom-usb", "/mnt/felhom-usb") without := ExportDataMounts(compose, "/mnt/felhom-usb", "") if len(withNS) != len(without) { t.Fatalf("fallback changed the result: %v vs %v", withNS, without) } for i := range withNS { if withNS[i] != without[i] { t.Fatalf("fallback changed %q to %q", withNS[i], without[i]) } } }