90a2530689
gates / gates (push) Successful in 48s
Found live on 9202: every MariaDB 11+ import timed out (mysqladmin/mysql absent). Red-proved.
275 lines
12 KiB
Go
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)
|
|
}
|
|
}
|