Files
felhom-controller/controller/internal/appexport/r1187_abdfg_test.go
T
admin 90a2530689
gates / gates (push) Successful in 48s
fix(R-1187): the .fab import loads the database through the backup side (mariadb-admin/mariadb, the container's own credentials)
Found live on 9202: every MariaDB 11+ import timed out (mysqladmin/mysql absent). Red-proved.
2026-10-26 21:58:11 +01:00

275 lines
12 KiB
Go

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)
}
}