a96c3d9473
gates / gates (push) Successful in 9s
ExportDataMounts lives in delete.go, which reads as a destructive path. IT IS NOT: its single production caller is the .fab export adapter, and nothing deletes based on its result. The delete path's own guard, ProtectedHDDPaths, is layout-agnostic by construction -- it protects BOTH <hdd>/... and <hdd>/felhom-data/... -- so deletion was never affected by the namespace-root defect. That scope note is now in the function's doc comment, because the file placement will mislead the next reader exactly as it misled the spec for this change. Separated into its own commit anyway, so a change to a function whose filename says "delete" is reviewable on its own. An empty nsRoot falls back to hddPath -- the pre-R-203 shape -- so any caller not yet updated keeps working on enrolled drives. Tests cover both drive kinds and assert the NEGATIVE: no emitted path lies outside the app's own data roots. Red-proof: leaving the site bare fails the system-drive row, emitting /mnt/sys_drive/userdata where the canonical root is /mnt/sys_drive/felhom-data/userdata.
202 lines
7.0 KiB
Go
202 lines
7.0 KiB
Go
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→<HDD_PATH>/<subdir> 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])
|
|
}
|
|
}
|
|
}
|