package appexport import ( "archive/tar" "io" "log" "os" "path/filepath" "strconv" "strings" "syscall" "testing" "time" ) // R-1187 (a), (b), (d), (f), (g) — the open parts of the `.fab` data-loss row. Each test asserts the CONSEQUENCE for // the household's files. Red-proved 2026-10-26 night against the code before the fix (function shapes added first with // the old behaviour, so every failure below was a behaviour failure, not a compile error). func r1187TarEntries(t *testing.T, path string) map[string]*tar.Header { t.Helper() f, err := os.Open(path) if err != nil { t.Fatal(err) } defer f.Close() out := map[string]*tar.Header{} tr := tar.NewReader(f) for { h, err := tr.Next() if err == io.EOF { return out } if err != nil { t.Fatalf("reading %s: %v", path, err) } out[h.Name] = h } } // (a) A symlink in a drive folder is carried AS A LINK — before, the walk wrote a link header and then copied the // target's bytes into it, the tar failed, and the whole folder was left out with only a WARN (the export ended „done"). func TestR1187a_ASymlinkIsCarriedAsALink(t *testing.T) { src := t.TempDir() os.WriteFile(filepath.Join(src, "photo.jpg"), []byte("jpeg"), 0o644) os.Symlink("photo.jpg", filepath.Join(src, "latest.jpg")) os.Symlink("/etc/hostname", filepath.Join(src, "outside")) out := filepath.Join(t.TempDir(), "x.tar") if err := tarDirectoryExcluding(src, out, nil); err != nil { t.Fatalf("a folder with symlinks could not be packed: %v", err) } e := r1187TarEntries(t, out) if h := e["latest.jpg"]; h == nil || h.Typeflag != tar.TypeSymlink || h.Linkname != "photo.jpg" { t.Fatalf("the inside link is not carried as a link: %+v", h) } if h := e["outside"]; h == nil || h.Typeflag != tar.TypeSymlink || h.Size != 0 { t.Fatalf("the outside link must be carried as a bare link (never its target's bytes): %+v", h) } if h := e["photo.jpg"]; h == nil || h.Typeflag != tar.TypeReg { t.Fatalf("the regular file is missing: %+v", h) } } // (a) A FIFO stops the export with a sentence naming the path — never a hang on os.Open, never a silent drop. The // export of the folder FAILS as a whole (exportHDDData returns the error; the job then fails). func TestR1187a_AFifoFailsTheExportNamingThePath(t *testing.T) { mount := filepath.Join(t.TempDir(), "appdata-x") os.MkdirAll(mount, 0o755) os.WriteFile(filepath.Join(mount, "keep.txt"), []byte("k"), 0o644) fifo := filepath.Join(mount, "pipe") if err := syscall.Mkfifo(fifo, 0o644); err != nil { t.Skipf("no FIFO here: %v", err) } prov := &r1187HDDProvider{rtProvider: rtProvider{stackDir: t.TempDir()}, mounts: []string{mount}} e := NewExporter(prov, log.New(io.Discard, "", 0), "test") done := make(chan error, 1) go func() { done <- e.exportHDDData(ExportRequest{StackName: "x"}, t.TempDir(), &Manifest{}) }() select { case err := <-done: if err == nil || !strings.Contains(err.Error(), fifo) { t.Fatalf("a FIFO must fail the export naming %s, got %v", fifo, err) } case <-time.After(10 * time.Second): t.Fatal("the export HUNG on a FIFO") } } type r1187HDDProvider struct { rtProvider mounts []string } func (p *r1187HDDProvider) GetStackHDDMounts(string) []string { return p.mounts } // (b) The import keeps owners and modes, restores a link that stays inside the app's folder, and refuses (and lists) a // link that points outside it. Before: only folders and regular files were written — owners lost, every link dropped. func TestR1187b_TheImportKeepsOwnersModesAndInsideLinks(t *testing.T) { tarPath := filepath.Join(t.TempDir(), "d.tar") f, _ := os.Create(tarPath) tw := tar.NewWriter(f) tw.WriteHeader(&tar.Header{Name: "sub/", Typeflag: tar.TypeDir, Mode: 0o750, Uid: 1000, Gid: 1000}) tw.WriteHeader(&tar.Header{Name: "sub/a.txt", Typeflag: tar.TypeReg, Mode: 0o640, Size: 2, Uid: 1000, Gid: 1001}) tw.Write([]byte("hi")) tw.WriteHeader(&tar.Header{Name: "link-in", Typeflag: tar.TypeSymlink, Linkname: "sub/a.txt", Uid: 1000, Gid: 1000}) tw.WriteHeader(&tar.Header{Name: "link-out", Typeflag: tar.TypeSymlink, Linkname: "../../etc/passwd"}) tw.WriteHeader(&tar.Header{Name: "link-abs", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}) tw.Close() f.Close() var chowned []string old := lchownFn lchownFn = func(_ *os.Root, p string, uid, gid int) error { chowned = append(chowned, filepath.Base(p)+":"+strconv.Itoa(uid)+":"+strconv.Itoa(gid)) return nil } defer func() { lchownFn = old }() dest := t.TempDir() refused, err := extractTar(tarPath, dest) if err != nil { t.Fatal(err) } if fi, err := os.Stat(filepath.Join(dest, "sub/a.txt")); err != nil || fi.Mode().Perm() != 0o640 { t.Fatalf("mode not kept: %v %v", fi, err) } if l, err := os.Readlink(filepath.Join(dest, "link-in")); err != nil || l != "sub/a.txt" { t.Fatalf("the inside link was not restored: %q %v", l, err) } for _, bad := range []string{"link-out", "link-abs"} { if _, err := os.Lstat(filepath.Join(dest, bad)); err == nil { t.Fatalf("a link pointing OUTSIDE the app's folder was restored: %s", bad) } } if strings.Join(refused, ",") != "link-out,link-abs" { t.Fatalf("the refused links must be listed, got %v", refused) } got := strings.Join(chowned, ",") for _, want := range []string{"sub:1000:1000", "a.txt:1000:1001", "link-in:1000:1000"} { if !strings.Contains(got, want) { t.Fatalf("owner %s not restored (got %s)", want, got) } } } // (d) An import is refused while another operation holds the app-stop marker (a restore, a put-back, a stop-first // export) — before, StartImport checked only its own job slot. func TestR1187d_AnImportWaitsForARestore(t *testing.T) { e := NewExporter(&rtProvider{stackDir: t.TempDir()}, log.New(io.Discard, "", 0), "test") e.SetStopGuard(&busyGuard{}) fab := filepath.Join(t.TempDir(), "x.fab") os.WriteFile(fab, []byte("x"), 0o644) err := e.StartImport(ImportRequest{FABPath: fab}) if err == nil || !strings.Contains(err.Error(), "unit-restore:bookstack") { t.Fatalf("an import started while a restore holds the box: %v", err) } if e.activeJob != nil { t.Fatal("a refused import created a job") } } // (f) A bundle whose app.yaml carries an empty or relative HDD_PATH is refused BEFORE anything is touched — before, an // empty one made the drive tars extract into a RELATIVE path (the controller's working directory). func TestR1187f_AnEmptyOrRelativeHDDPathIsRefused(t *testing.T) { for _, tc := range []struct { hdd string ok bool }{{"", false}, {"data", false}, {"/mnt/drive/../../etc", false}, {"/mnt/sys_drive", true}} { tmp := t.TempDir() os.MkdirAll(filepath.Join(tmp, "config"), 0o755) os.WriteFile(filepath.Join(tmp, "config", "app.yaml"), []byte("env:\n HDD_PATH: \""+tc.hdd+"\"\n"), 0o644) os.MkdirAll(filepath.Join(tmp, "data", "hdd"), 0o755) os.WriteFile(filepath.Join(tmp, "data", "hdd", "appdata.tar"), []byte("not empty"), 0o644) err := validateBundleData(tmp, &Manifest{HDDSubdirs: []string{"appdata"}, HasHDDData: true}) if tc.ok != (err == nil) { t.Errorf("HDD_PATH %q: refused=%v, want refused=%v (%v)", tc.hdd, err != nil, !tc.ok, err) } } } // (g) PostGIS / pgvector / timescaledb images are PostgreSQL for the import too (the backup side's predicate, R-484), // and psql stops on the first error (the dump is --clean --if-exists, so ON_ERROR_STOP is safe). func TestR1187g_PostgisIsPostgresAndPsqlStopsOnError(t *testing.T) { for _, img := range []string{"postgis/postgis:16-3.5-alpine", "pgvector/pgvector:pg16", "timescale/timescaledb:2.14.2-pg16", "postgres:16"} { if info := classifyDBImage("db", img, "c"); info == nil || info.DBType != "postgres" { t.Errorf("%s is not classified as PostgreSQL: %+v", img, info) } } // The load itself goes through appbackup.ImportDump (psql -v ON_ERROR_STOP=1 --single-transaction) since the // import's own client calls were removed — TestR1187_TheImportLoadsTheDumpThroughTheBackupSide. } // (b), the security review's case: a link already in the destination folder (left by the old app, or made by an earlier // entry) that points OUTSIDE must never be written through — a tar entry „evil/passwd" would otherwise create a file // wherever „evil" points. Every write is confined to the app's folder (os.Root). RED-PROOF: with plain os.Create the // outside file is created. func TestR1187b_NoWriteThroughALinkThatLeavesTheFolder(t *testing.T) { outside := t.TempDir() dest := t.TempDir() os.Symlink(outside, filepath.Join(dest, "evil")) // pre-existing, pointing out tarPath := filepath.Join(t.TempDir(), "d.tar") f, _ := os.Create(tarPath) tw := tar.NewWriter(f) tw.WriteHeader(&tar.Header{Name: "evil/passwd", Typeflag: tar.TypeReg, Mode: 0o644, Size: 3}) tw.Write([]byte("pwn")) tw.WriteHeader(&tar.Header{Name: "ok.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 2}) tw.Write([]byte("ok")) tw.Close() f.Close() old := lchownFn lchownFn = func(*os.Root, string, int, int) error { return nil } defer func() { lchownFn = old }() extractTar(tarPath, dest) if _, err := os.Stat(filepath.Join(outside, "passwd")); err == nil { t.Fatal("a file was written OUTSIDE the app's folder through a pre-existing link") } } // (b), the security review's second case — a parser differential: link „a" → „." makes „a/b" really „b", so a later // link „a/b/l" → „../.." passes a path-TEXT check (root/a/b/../.. = root) while it really points one level ABOVE the // app's folder. A link entry whose parent path passes through a link is refused and listed. RED-PROOF: with the text // check only, „a/b/l" is created and points outside. func TestR1187b_NoLinkThroughALinkedParent(t *testing.T) { dest := t.TempDir() tarPath := filepath.Join(t.TempDir(), "d.tar") f, _ := os.Create(tarPath) tw := tar.NewWriter(f) tw.WriteHeader(&tar.Header{Name: "a", Typeflag: tar.TypeSymlink, Linkname: "."}) tw.WriteHeader(&tar.Header{Name: "a/b/", Typeflag: tar.TypeDir, Mode: 0o755}) tw.WriteHeader(&tar.Header{Name: "a/b/l", Typeflag: tar.TypeSymlink, Linkname: "../.."}) tw.Close() f.Close() old := lchownFn lchownFn = func(*os.Root, string, int, int) error { return nil } defer func() { lchownFn = old }() refused, err := extractTar(tarPath, dest) if err != nil { t.Fatal(err) } for _, p := range []string{filepath.Join(dest, "b", "l"), filepath.Join(dest, "a", "b", "l")} { if _, err := os.Lstat(p); err == nil { real, _ := filepath.EvalSymlinks(p) t.Fatalf("a link through a linked parent was created (%s → %s)", p, real) } } if !strings.Contains(strings.Join(refused, ","), "a/b/l") { t.Fatalf("the refused link must be listed, got %v", refused) } } // (b), the security review's third case: a link TARGET that passes through a link already in the folder („evil" → an // outside folder, then „x" → „evil/y") reads as inside by its text and really points outside. Restored links may only // point DOWNWARD to real folders: no absolute target, no „..", no link on the way. RED-PROOF: the text check created „x". func TestR1187b_NoLinkTargetThroughALink(t *testing.T) { outside := t.TempDir() dest := t.TempDir() os.Symlink(outside, filepath.Join(dest, "evil")) tarPath := filepath.Join(t.TempDir(), "d.tar") f, _ := os.Create(tarPath) tw := tar.NewWriter(f) tw.WriteHeader(&tar.Header{Name: "x", Typeflag: tar.TypeSymlink, Linkname: "evil/y"}) tw.WriteHeader(&tar.Header{Name: "up", Typeflag: tar.TypeSymlink, Linkname: "sub/../sub"}) tw.Close() f.Close() old := lchownFn lchownFn = func(*os.Root, string, int, int) error { return nil } defer func() { lchownFn = old }() refused, _ := extractTar(tarPath, dest) if _, err := os.Lstat(filepath.Join(dest, "x")); err == nil { t.Fatal("a link whose target passes through an outside-pointing link was created") } if strings.Join(refused, ",") != "x,up" { t.Fatalf("both links must be refused and listed, got %v", refused) } }