fix(R-1187 a,b,d,f,g): .fab — links carried and restored safely, FIFOs refused, owners kept, busy and bad-path refusals, postgis + ON_ERROR_STOP
Red-proved (6 tests). Commit only.
This commit is contained in:
@@ -23,6 +23,17 @@ hub answers 400 to both and nothing else changes.
|
||||
/api/escrow/start` answers 409 with `api.escrow.recovery_offer_open` (hu + en) before anything is staged or started.
|
||||
Tests `TestR1219_*` (the bar with a real session, and the same box without an offer as the control), red-proved (bar
|
||||
shown, start 200).
|
||||
- **R-1187 (P2, `.fab` export/import) — (a), (b), (d), (f), (g):** (a) the export carries a symlink AS A LINK (never its
|
||||
target's bytes) and stops with `err.appexport.kulonleges_fajl` naming the path on a FIFO, socket or device (a FIFO used
|
||||
to hang it); a drive folder that cannot be packed now FAILS the export (it was left out with a WARN while the export
|
||||
ended „done"). (b) the import keeps owners (`Lchown`) and modes and restores a link that stays inside the app's folder;
|
||||
a link pointing outside is refused and listed in a WARN. (d) `StartImport` refuses (`err.appexport.import_foglalt`)
|
||||
while another operation holds the app-stop marker (`BusyWith`). (f) a bundle whose `app.yaml` carries an empty,
|
||||
relative or unclean `HDD_PATH` is refused before anything is touched (`err.appexport.hdd_path_hibas`). (g) the import
|
||||
classifies databases with the backup side's predicate (`appbackup.DBTypeForImage`: postgis, pgvector, timescaledb) and
|
||||
runs `psql -v ON_ERROR_STOP=1`. Tests `internal/appexport/r1187_abdfg_test.go` (6), red-proved with the old behaviour
|
||||
behind the new function shapes (link packing error, a 10-s hang on the FIFO, link not restored, import not refused,
|
||||
bad paths accepted, postgis missed).
|
||||
- `settings.RestoreHold.Why()` / `WireReason()`: the hold sentence moved out of `cmd/controller` (`holdWhy` calls it), so
|
||||
`--restore-holds` and the hub's list say the same thing.
|
||||
- Tests `internal/holdreport/holdreport_test.go` (8): the report field, the daily repeat, cleared once, cleared while
|
||||
|
||||
@@ -42,6 +42,9 @@ func dbTypeForImage(image string) (DBType, bool) {
|
||||
return DBType(""), false
|
||||
}
|
||||
|
||||
// DBTypeForImage is dbTypeForImage for other packages (R-1187 g: the .fab import classifies with the SAME predicate).
|
||||
func DBTypeForImage(image string) (DBType, bool) { return dbTypeForImage(image) }
|
||||
|
||||
// composeServicesDoc is the minimal view of a compose file needed here: the `services:` MAP and each
|
||||
// service's `image:`. Deliberately a real YAML parse and not a line scan — a top-level `volumes:`
|
||||
// block (immich's `immich_ml_cache:`) has exactly the shape a naive scan misreads as a service, and
|
||||
|
||||
@@ -728,10 +728,11 @@ func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Ma
|
||||
e.debugf("tarring HDD mount: %s → %s (%d exclude(s))", mount, tarPath, len(excludes))
|
||||
tarStart := time.Now()
|
||||
if err := tarDirectoryExcluding(mount, tarPath, excludes); err != nil {
|
||||
// v0.125.0: claim the subdir ONLY on success — a claimed-but-absent tar would trip
|
||||
// the packaging assertion; an honestly-skipped mount stays out of the manifest.
|
||||
e.logger.Printf("[WARN] Export: failed to tar %s (excluded from the bundle): %v", mount, err)
|
||||
// R-1187 (a): a folder that cannot be packed FAILS the export. It used to be left out with a WARN while the
|
||||
// export ended „done" — a bundle silently missing a whole drive folder. Pinned by TestR1187a_*.
|
||||
e.logger.Printf("[WARN] Export: failed to pack %s — the export FAILS rather than leave it out (R-1187 a): %v", mount, err)
|
||||
os.Remove(tarPath)
|
||||
return err
|
||||
} else {
|
||||
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
|
||||
if info, _ := os.Stat(tarPath); info != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package appexport
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -289,7 +290,20 @@ func tarDirectoryExcluding(sourceDir, outputPath string, excludeRels []string) e
|
||||
return nil
|
||||
}
|
||||
}
|
||||
header, err := tar.FileInfoHeader(info, "")
|
||||
// R-1187 (a): a symlink is carried AS A LINK (its target's bytes never — the walk used to copy them into the link
|
||||
// header, the tar failed, and the whole folder was left out); a FIFO, socket or device stops the export with a
|
||||
// sentence naming the path (os.Open on a FIFO used to HANG the export). Pinned by TestR1187a_*.
|
||||
mode := info.Mode()
|
||||
if mode&(os.ModeNamedPipe|os.ModeSocket|os.ModeDevice|os.ModeCharDevice|os.ModeIrregular) != 0 {
|
||||
return util.MsgError("err.appexport.kulonleges_fajl", path)
|
||||
}
|
||||
link := ""
|
||||
if mode&os.ModeSymlink != 0 {
|
||||
if link, err = os.Readlink(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
header, err := tar.FileInfoHeader(info, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -297,7 +311,7 @@ func tarDirectoryExcluding(sourceDir, outputPath string, excludeRels []string) e
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
if info.IsDir() || link != "" {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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(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)
|
||||
}
|
||||
}
|
||||
args := strings.Join(psqlImportArgs("c", "u", "d"), " ")
|
||||
if !strings.Contains(args, "-v ON_ERROR_STOP=1") {
|
||||
t.Fatalf("psql does not stop on error: %s", args)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
@@ -184,6 +185,16 @@ func (e *Exporter) StartImport(req ImportRequest) error {
|
||||
e.debugf("StartImport rejected: another job is already running")
|
||||
return fmt.Errorf("export or import already in progress")
|
||||
}
|
||||
// R-1187 (d): another operation holds the app-stop marker (a restore, a put-back, a stop-first export) — an import
|
||||
// now would stop and rewrite an app that operation is moving. Refused before anything is read. Pinned by
|
||||
// TestR1187d_AnImportWaitsForARestore.
|
||||
if e.stopGuard != nil {
|
||||
if busy := e.stopGuard.BusyWith(); busy != "" {
|
||||
e.mu.Unlock()
|
||||
e.logger.Printf("[WARN] Import: refused — another operation holds the box (%s) (R-1187 d)", busy)
|
||||
return util.MsgError("err.appexport.import_foglalt", busy)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file exists
|
||||
info, err := os.Stat(req.FABPath)
|
||||
@@ -674,9 +685,14 @@ func (e *Exporter) restoreHDDData(tmpDir string, manifest *Manifest, composePath
|
||||
|
||||
e.logger.Printf("[INFO] Import: extracting HDD data %s → %s", subdir, destPath)
|
||||
extractStart := time.Now()
|
||||
if err := extractTar(tarPath, destPath); err != nil {
|
||||
refused, err := extractTar(tarPath, destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extracting %s: %w", subdir, err)
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
e.logger.Printf("[WARN] Import: %s — %d link(s) pointing outside the app's folder were NOT restored (R-1187 b): %s",
|
||||
subdir, len(refused), strings.Join(refused, ", "))
|
||||
}
|
||||
e.debugf("restoreHDDData: extracted %s in %v", subdir, time.Since(extractStart))
|
||||
}
|
||||
|
||||
@@ -726,6 +742,16 @@ func resolveHDDMounts(composePath string, env map[string]string) []string {
|
||||
// validateBundleData asserts every manifest-claimed data tar exists non-empty in the extracted
|
||||
// bundle (v0.125.0, scenario C). Pure read — called before ANY destructive import step.
|
||||
func validateBundleData(tmpDir string, manifest *Manifest) error {
|
||||
// R-1187 (f): drive data needs an ABSOLUTE, clean HDD_PATH from the bundle's app.yaml — an empty one made the drive
|
||||
// tars extract into a relative path (the controller's working directory). Refused before anything is touched.
|
||||
// Pinned by TestR1187f_AnEmptyOrRelativeHDDPathIsRefused.
|
||||
if len(manifest.HDDSubdirs) > 0 {
|
||||
env, _ := readPlaintextAppYaml(filepath.Join(tmpDir, "config", "app.yaml"))
|
||||
hdd := strings.TrimSpace(env["HDD_PATH"])
|
||||
if hdd == "" || !filepath.IsAbs(hdd) || filepath.Clean(hdd) != hdd || strings.Contains(hdd, "..") {
|
||||
return util.MsgError("err.appexport.hdd_path_hibas", hdd)
|
||||
}
|
||||
}
|
||||
for _, v := range manifest.VolumeNames {
|
||||
if err := ValidateSegment("volume_name", v); err != nil {
|
||||
return err
|
||||
@@ -967,11 +993,12 @@ func findDBServiceInCompose(composePath string) *dbServiceInfo {
|
||||
}
|
||||
|
||||
func classifyDBImage(service, image, container string) *dbServiceInfo {
|
||||
img := strings.ToLower(image)
|
||||
if strings.Contains(img, "postgres") {
|
||||
// R-1187 (g): the backup side's predicate (appbackup.DBTypeForImage, R-484) — postgis / pgvector / timescaledb are
|
||||
// PostgreSQL too. The import used its own narrower list and skipped adventurelog's and dawarich's database.
|
||||
switch t, ok := appbackup.DBTypeForImage(image); {
|
||||
case ok && t == appbackup.DBTypePostgres:
|
||||
return &dbServiceInfo{ServiceName: service, DBType: "postgres", ContainerName: container}
|
||||
}
|
||||
if strings.Contains(img, "mariadb") || strings.Contains(img, "mysql") {
|
||||
case ok && t == appbackup.DBTypeMariaDB:
|
||||
return &dbServiceInfo{ServiceName: service, DBType: "mariadb", ContainerName: container}
|
||||
}
|
||||
return nil
|
||||
@@ -1056,6 +1083,13 @@ func waitForDB(containerID, dbType string, env map[string]string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// psqlImportArgs is the `docker` argv that loads a Postgres dump.
|
||||
func psqlImportArgs(containerID, user, dbName string) []string {
|
||||
// R-1187 (g): stop on the first error — the dump is --clean --if-exists, so a failing statement is a real failure,
|
||||
// and without ON_ERROR_STOP psql went on and the import reported success over a half-loaded database.
|
||||
return []string{"exec", "-i", containerID, "psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", dbName}
|
||||
}
|
||||
|
||||
// importDBDump imports a (possibly gzipped) SQL dump into the running database container.
|
||||
func importDBDump(containerID, dumpPath, dbType string, env map[string]string) error {
|
||||
// Open the dump file
|
||||
@@ -1090,8 +1124,7 @@ func importDBDump(containerID, dumpPath, dbType string, env map[string]string) e
|
||||
if dbName == "" {
|
||||
dbName = user
|
||||
}
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", "exec", "-i", containerID,
|
||||
"psql", "-U", user, "-d", dbName)
|
||||
cmd = dockerexec.CommandContext(ctx, "docker", psqlImportArgs(containerID, user, dbName)...)
|
||||
case "mariadb":
|
||||
password := env["MYSQL_ROOT_PASSWORD"]
|
||||
if password == "" {
|
||||
@@ -1170,50 +1203,80 @@ func extractTarGz(tgzPath, destDir string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// extractTar extracts a plain tar archive to a directory.
|
||||
func extractTar(tarPath, destDir string) error {
|
||||
// lchownFn is the owner seam (a test records it; production is os.Lchown).
|
||||
var lchownFn = os.Lchown
|
||||
|
||||
// extractTar extracts a plain tar archive (one drive folder) into destDir and returns the entries it REFUSED.
|
||||
//
|
||||
// R-1187 (b): owners and modes are kept (Lchown — the controller runs as root on a box), and a symlink is restored when
|
||||
// it stays inside destDir; a link pointing outside (absolute, or climbing out with ..) is refused and listed — never
|
||||
// followed, never created. Only folders, regular files and links exist in a drive tar (the export refuses the rest).
|
||||
// Pinned by TestR1187b_TheImportKeepsOwnersModesAndInsideLinks.
|
||||
func extractTar(tarPath, destDir string) ([]string, error) {
|
||||
f, err := os.Open(tarPath)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
root := filepath.Clean(destDir)
|
||||
var refused []string
|
||||
tr := tar.NewReader(f)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
return refused, nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return refused, err
|
||||
}
|
||||
|
||||
target := filepath.Join(destDir, hdr.Name)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) {
|
||||
continue
|
||||
target := filepath.Join(root, hdr.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(target), root+string(os.PathSeparator)) {
|
||||
continue // path traversal
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil {
|
||||
return err
|
||||
if err := os.MkdirAll(target, os.FileMode(hdr.Mode).Perm()); err != nil {
|
||||
return refused, err
|
||||
}
|
||||
os.Chmod(target, os.FileMode(hdr.Mode).Perm())
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return err
|
||||
return refused, err
|
||||
}
|
||||
outFile, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
return refused, err
|
||||
}
|
||||
if _, err := io.Copy(outFile, tr); err != nil {
|
||||
outFile.Close()
|
||||
return err
|
||||
return refused, err
|
||||
}
|
||||
outFile.Close()
|
||||
os.Chmod(target, os.FileMode(hdr.Mode))
|
||||
os.Chmod(target, os.FileMode(hdr.Mode).Perm())
|
||||
case tar.TypeSymlink:
|
||||
dst := hdr.Linkname
|
||||
if filepath.IsAbs(dst) {
|
||||
refused = append(refused, hdr.Name)
|
||||
continue
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(target), dst))
|
||||
if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
|
||||
refused = append(refused, hdr.Name)
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return refused, err
|
||||
}
|
||||
os.Remove(target)
|
||||
if err := os.Symlink(dst, target); err != nil {
|
||||
return refused, err
|
||||
}
|
||||
default:
|
||||
refused = append(refused, hdr.Name)
|
||||
continue
|
||||
}
|
||||
if err := lchownFn(target, hdr.Uid, hdr.Gid); err != nil && !os.IsPermission(err) {
|
||||
return refused, fmt.Errorf("setting the owner of %s: %w", hdr.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,9 @@
|
||||
"err.appexport.a_z_kotet_adata_hianyzik_a": "the data of volume %q is missing from the package",
|
||||
"err.appexport.jelszo_hibas_vagy_a_fajl_serult": "wrong password, or the file is damaged",
|
||||
"err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "two data folders end in the same name (%q: %s and %s) — the package could not tell them apart",
|
||||
"err.appexport.kulonleges_fajl": "%s is a special file (a pipe, socket or device) that cannot be saved — the export stopped; nothing was left out silently",
|
||||
"err.appexport.import_foglalt": "a package cannot be loaded right now: another operation is working on the box (%s). Wait until it finishes and try again.",
|
||||
"err.appexport.hdd_path_hibas": "the data-drive path in the package is not valid (“%s”) — the import did not start, nothing changed",
|
||||
"err.appexport.visszafejtes_sikertelen": "decryption failed: %s",
|
||||
"err.backup.whole_restore_space": "There is not enough room on the drive to bring the files back: %.1f GB needed, %.1f GB free. Nothing was changed.",
|
||||
"err.backup.whole_restore_no_proven_copy": "The second drive holds no checked copy that can bring the app back together with its files. Nothing was changed.",
|
||||
|
||||
@@ -1253,6 +1253,9 @@
|
||||
"err.appexport.a_z_kotet_adata_hianyzik_a": "a(z) %q kötet adata hiányzik a csomagból",
|
||||
"err.appexport.jelszo_hibas_vagy_a_fajl_serult": "jelszó hibás vagy a fájl sérült",
|
||||
"err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket",
|
||||
"err.appexport.kulonleges_fajl": "a(z) %s különleges fájl (cső, foglalat vagy eszköz), ezt nem lehet menteni — az exportálás leállt, semmi nem maradt ki csendben",
|
||||
"err.appexport.import_foglalt": "most nem tölthetsz vissza csomagot: egy másik művelet dolgozik a dobozon (%s). Várd meg, míg befejeződik, és próbáld újra.",
|
||||
"err.appexport.hdd_path_hibas": "a csomag adatmeghajtó-útvonala hibás („%s”) — a betöltés el sem indult, semmi nem változott",
|
||||
"err.appexport.visszafejtes_sikertelen": "visszafejtés sikertelen: %s",
|
||||
"err.backup.whole_restore_space": "Nincs elég hely a meghajtón a fájlok visszaállításához: %.1f GB kellene, %.1f GB szabad. Semmi nem változott.",
|
||||
"err.backup.whole_restore_no_proven_copy": "A második meghajtón nincs olyan ellenőrzött másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. Semmi nem változott.",
|
||||
|
||||
Reference in New Issue
Block a user