diff --git a/CHANGELOG.md b/CHANGELOG.md index b58bb845..91f2ef93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/controller/internal/appbackup/dbservices.go b/controller/internal/appbackup/dbservices.go index 585d9377..67e854f3 100644 --- a/controller/internal/appbackup/dbservices.go +++ b/controller/internal/appbackup/dbservices.go @@ -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 diff --git a/controller/internal/appexport/export.go b/controller/internal/appexport/export.go index f95aec9b..b4dddbdc 100644 --- a/controller/internal/appexport/export.go +++ b/controller/internal/appexport/export.go @@ -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 { diff --git a/controller/internal/appexport/fabplan.go b/controller/internal/appexport/fabplan.go index 01b56188..85034d07 100644 --- a/controller/internal/appexport/fabplan.go +++ b/controller/internal/appexport/fabplan.go @@ -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) diff --git a/controller/internal/appexport/r1187_abdfg_test.go b/controller/internal/appexport/r1187_abdfg_test.go new file mode 100644 index 00000000..07ba147b --- /dev/null +++ b/controller/internal/appexport/r1187_abdfg_test.go @@ -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) + } +} diff --git a/controller/internal/appexport/restore.go b/controller/internal/appexport/restore.go index bf170cd2..53a88b82 100644 --- a/controller/internal/appexport/restore.go +++ b/controller/internal/appexport/restore.go @@ -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) } } } diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 65b0c81f..22e3890c 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -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.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 39a6e9c4..0c139783 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -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.",