From 1453cfc69b9b630f0d2871f8cae23eed1b1f0978 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 20:12:56 +0200 Subject: [PATCH] =?UTF-8?q?v0.297.0:=20burn-down=20round=202=20=E2=80=94?= =?UTF-8?q?=2024=20small=20rows=20(R-591=20R-568=20R-567=20R-363=20R-547?= =?UTF-8?q?=20R-10=20R-552=20R-251=20R-104=20R-619=20R-362=20R-675=20R-256?= =?UTF-8?q?=20R-257=20R-240=20R-365=20R-425=20R-565=20R-564=20R-603=20R-45?= =?UTF-8?q?4=20R-208=20R-457-swept)=20+=20the=20banner=20countdown=20and?= =?UTF-8?q?=20deepCopyStack=20twins;=20MinAgent=200.131.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 31 +- REPORT.md | 13 +- controller/Dockerfile | 9 +- controller/cmd/controller/main.go | 27 +- .../controller/r208_dockerfile_args_test.go | 56 ++ .../r363_fillwatch_interval_test.go | 58 ++ controller/internal/agentapi/diskverdict.go | 28 +- .../api/r552_remove_clears_notice_test.go | 89 +++ .../api/r619_password_required_test.go | 89 +++ controller/internal/api/router.go | 31 + controller/internal/api/update_reason_test.go | 4 +- controller/internal/appbackup/dbdump.go | 19 + .../internal/appbackup/namespace_root_test.go | 2 +- .../appbackup/r10_dump_dirsync_test.go | 56 ++ .../appbackup/r381_undo_naming_test.go | 6 +- controller/internal/backup/backup.go | 3 + controller/internal/backup/offbox.go | 33 +- .../internal/backup/offbox_inventory.go | 10 +- controller/internal/backup/offbox_restore.go | 2 +- controller/internal/backup/offbox_test.go | 4 +- .../internal/backup/offsite_diag_test.go | 2 +- .../internal/backup/r104_lock_class_test.go | 55 ++ .../backup/r240_zero_selection_test.go | 41 + .../internal/backup/r251_marker_tag_test.go | 61 ++ .../backup/r362_restore_drive_gone_test.go | 77 ++ .../backup/r553_offsite_quota_test.go | 2 +- .../internal/backup/r669_applied_meta_test.go | 2 +- controller/internal/backup/restore_dir_err.go | 78 ++ controller/internal/backup/restore_record.go | 19 + controller/internal/backup/shares_restore.go | 2 +- controller/internal/family/family.go | 2 +- controller/internal/i18n/locales/en.json | 16 +- controller/internal/i18n/locales/hu.json | 16 +- controller/internal/i18n/r603_escape_test.go | 143 ++++ controller/internal/infra/infra.go | 4 +- .../internal/notify/r636_oom_storm_test.go | 2 +- controller/internal/quiesce/tiers_test.go | 1 + controller/internal/stacks/delete.go | 12 +- controller/internal/stacks/life_records.go | 2 +- controller/internal/stacks/manager.go | 66 ++ controller/internal/stacks/metadata_i18n.go | 90 +++ .../internal/stacks/r591_copy_i18n_test.go | 61 ++ .../stacks/r591_copy_meta_alias_test.go | 109 +++ controller/internal/web/disk_health.go | 11 +- controller/internal/web/handlers.go | 53 +- controller/internal/web/i18n_cases_b_test.go | 8 + controller/internal/web/i18n_parity_test.go | 72 ++ .../internal/web/offsite_restore_list.go | 2 +- .../web/r256_r257_offbox_refusals_test.go | 76 ++ .../internal/web/r365_abandon_overdue_test.go | 51 ++ .../internal/web/r365_banner_overdue_test.go | 57 ++ .../web/r567_storage_wizard_nav_test.go | 27 + .../internal/web/r568_disk_order_test.go | 33 + .../web/r675_refusal_whole_copy_test.go | 51 ++ controller/internal/web/recovery_handlers.go | 5 + .../web/templates/backups_remote.html | 4 +- controller/internal/web/templates/layout.html | 12 +- .../backups_remote_abandon_overdue.html | 720 ++++++++++++++++++ .../i18n_parity/launcher_abandon_overdue.html | 603 +++++++++++++++ .../launcher_abandon_overdue_offered.html | 603 +++++++++++++++ .../testdata/i18n_parity/storage_attach.html | 6 +- .../testdata/i18n_parity/storage_init.html | 6 +- controller/scripts/controller_gates.py | 4 + controller/scripts/gofmt_gate.py | 64 ++ controller/scripts/i18n_go_keys.json | 17 +- controller/scripts/offbox_rename_gate.py | 66 +- controller/scripts/retrieval_promise_gate.py | 42 +- controller/scripts/test_gate_decoys.py | 43 ++ 68 files changed, 3953 insertions(+), 116 deletions(-) create mode 100644 controller/cmd/controller/r208_dockerfile_args_test.go create mode 100644 controller/cmd/controller/r363_fillwatch_interval_test.go create mode 100644 controller/internal/api/r552_remove_clears_notice_test.go create mode 100644 controller/internal/api/r619_password_required_test.go create mode 100644 controller/internal/appbackup/r10_dump_dirsync_test.go create mode 100644 controller/internal/backup/r104_lock_class_test.go create mode 100644 controller/internal/backup/r240_zero_selection_test.go create mode 100644 controller/internal/backup/r251_marker_tag_test.go create mode 100644 controller/internal/backup/r362_restore_drive_gone_test.go create mode 100644 controller/internal/backup/restore_dir_err.go create mode 100644 controller/internal/i18n/r603_escape_test.go create mode 100644 controller/internal/stacks/r591_copy_i18n_test.go create mode 100644 controller/internal/stacks/r591_copy_meta_alias_test.go create mode 100644 controller/internal/web/r256_r257_offbox_refusals_test.go create mode 100644 controller/internal/web/r365_abandon_overdue_test.go create mode 100644 controller/internal/web/r365_banner_overdue_test.go create mode 100644 controller/internal/web/r567_storage_wizard_nav_test.go create mode 100644 controller/internal/web/r568_disk_order_test.go create mode 100644 controller/internal/web/r675_refusal_whole_copy_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html create mode 100644 controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue.html create mode 100644 controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue_offered.html create mode 100644 controller/scripts/gofmt_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dba2fd1..dc13673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,33 @@ -## unreleased — a comment pinned by a test, a comment made exact; no binary change (burn-down 2026-10-05: R-263, R-368) +## v0.297.0 — small fixes from the burn-down: plain refusals that name a route, honest countdowns, a locked off-site store named, no shared metadata, gofmt gated (24 rows) (2026-10-05) -The next release carries these two lines into its own entry. +**MinAgent: 0.131.0** (unchanged). New household strings (hu + en): `note.offsite.fail_locked`, +`err.backup.restore_drive_gone`, `restore.refuse.files.*` (incl. `second_drive_whole`), `backups_remote.abandon_overdue`, +`layout.abandon_overdue`, `backups_remote.elapsed_seconds_unit`; reworded: `flash.offbox.mgr_unavailable`, +`flash.offbox.mgr_unreachable`, `flash.offbox.not_orphaned`, the zero-selection run note. + +- **R-591 (+ its twin):** `deepCopyStack` copies every reference reachable from `Meta` — the i18n overlay, DataPaths, + AfterLoad, AfterInstall, AfterSetup, SetupDoneProbe, SMTPMapping.Extra, Backup lists, FamilyGateExcept and AppInfo lists + were shared (`TestDeepCopyStackI18nIsNotShared`, `TestDeepCopyStackMetaSharesNoReference`). +- **R-568:** disk-health rows sorted by durable id. **R-567:** the storage wizards open the Storage nav group. +- **R-363 + R-547:** the fill-watch also runs every 10 min (the 03:30 run and the start-up check stay). +- **R-10:** the DB-dump directory is fsynced after the rename. **R-552:** removing an app clears its interrupted-restore + notice. **R-251:** the off-site marker tag is not listed as an app. +- **R-104:** a repository still locked after the self-heal is named as „locked" with its own cause line. +- **R-619:** password deploy fields are served as required by the deploy API. **R-362:** a restore onto a detached drive + names the drive instead of a raw permission error. **R-675:** the files-restore refusal names the second drive's whole + copy when there is one (and is now in both languages — it was Hungarian-only). +- **R-256, R-257, R-240:** off-site refusals say what happened and where to go; a run that selected nothing no longer + says „Sikeres". **R-365 (+ the banner):** a deletion countdown at 0 days says it is due (page and top bar) instead of + a future-tense or reminder sentence. +- **R-425, R-564, R-565, R-603:** the off-site rename gate finds files by pattern; the retrieval-promise gate knows + split-verb Hungarian; the English-page test knows ASCII Hungarian words; a Go-side check for HTML-escapable values. +- **R-454:** `scripts/gofmt_gate.py` (registered, with a decoy); 12 files formatted. **R-208:** `ARG VERSION`/`GIT_COMMIT` + sit just above `go build`. **R-457:** swept, no date literal feeds a clock assertion — no change. + +Red-proofs: `felhom.eu/documentation/audits/burndown2-2026-10-05/controller-red-proofs.txt` (two first attempts that did +not convict are marked there, with the valid re-runs). + +Also in this release (from burn-down round 1, no behaviour change): - **R-263:** `SetBackupTarget` is „the only writer that GRANTS" `StoragePath.BackupTarget` (ClearBackupTarget also writes it, only `false`), now pinned by `internal/settings/r263_backup_target_writers_test.go`: an AST scan of every non-test diff --git a/REPORT.md b/REPORT.md index 9edfee4..f42cced 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,8 +1,11 @@ -# REPORT — 2026-10-05 burn-down: a pinned invariant and an exact comment (no release) +# REPORT — controller v0.297.0 (2026-10-05, burn-down round 2) -Full session report: `felhom.eu/REPORT-burndown-2026-10-05.md`. Baseline `e563733` (v0.296.0). No binary change. +Full session report: `felhom.eu/REPORT-burndown2-2026-10-05.md`. Baseline `114ff27` (v0.296.0). **MinAgent 0.131.0**. -- R-263 — `internal/settings/r263_backup_target_writers_test.go` (AST scan of internal/ + cmd/); two red-proofs convict. -- R-368 — `internal/settings/settings.go` `IsDefault` comment names the deploy form as the one that applies it. +24 rows fixed (see the CHANGELOG entry), plus two small twins found on the way (the banner countdown at 0 days; the rest +of `deepCopyStack`'s shared references). R-126 and R-325 NOT done: R-126 needs an operator choice between two options; +R-325 needs a same-step change in felhom.eu's `hub_copy_gate.py`. Tests + red-proofs: +`felhom.eu/documentation/audits/burndown2-2026-10-05/controller-red-proofs.txt`. -`go build ./... && go vet ./... && go test ./...` green; `controller_gates.py --fast` green. +`go build/vet/test ./...` green; `controller_gates.py --fast` green (incl. the new gofmt gate). Image, golden and +delivery: see the session report. diff --git a/controller/Dockerfile b/controller/Dockerfile index 7aab46f..d2a3662 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -9,8 +9,6 @@ FROM golang:1.24-bookworm AS builder ARG TARGETOS=linux ARG TARGETARCH -ARG VERSION=dev -ARG GIT_COMMIT=unknown WORKDIR /build @@ -24,6 +22,13 @@ COPY . . # Generate go.sum and ensure all deps are fetched RUN go mod tidy +# R-208: the per-build values are declared HERE, just above the only step that reads them. A RUN's +# cache key includes the stage's ARG environment, so declared above `go mod download` a fresh VERSION +# re-ran the module download on every build (208 cache records, each used once, ~440 MB apiece). +# Pinned by TestR208_DockerfileVersionArgsSitBelowModuleDownload. +ARG VERSION=dev +ARG GIT_COMMIT=unknown + # Build static binary RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ -ldflags="-s -w \ diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 22ddd98..89af2e3 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -789,7 +789,7 @@ func main() { } if backupMgr != nil && cfg.Offsite.Enabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" { bridge := &offsiteapply.Bridge{ - Cfg: cfg, + Cfg: cfg, // Decision 69 (v0.289.0): the box sends its PUBLIC key to the hub's registrar, which pins it // append-only; the box never receives the sub-account password. Registrar: offsiteRegistrar, @@ -1507,13 +1507,18 @@ func main() { // every driveless app's recovery unit) were invisible, and it never reported a free-byte figure or // named a drive. // - // CADENCE: DAILY, at 03:30. A fill is a slow-moving quantity — the thing that fills a disk is a - // customer's photo library or a nightly backup, not a spike — so a shorter interval buys no - // earlier warning and only costs statfs calls. 03:30 is deliberately BEFORE the nightly app-data - // legs (db-dump / tier2 / offbox), so a customer who is about to lose a backup to lack of space - // hears about it while there is still a night's margin, rather than after the failure. - // The interval is NOT a cooldown: repeats are impossible because the check is edge-triggered per - // filesystem, and the hub owns cooldown regardless. + // CADENCE: every fillWatchInterval (10 min), PLUS the daily 03:30 run, PLUS once after startup. + // The original reasoning was "a fill is slow-moving, so daily is enough"; two measurements proved + // it wrong. R-363 (2026-08-21): a filesystem filled to 99% just after a daily run and the watcher + // said nothing while the backup reserve was already refusing apps. R-547 (2026-09-17): a root + // filesystem held at 96% for ten minutes raised no alarm of any kind. A daily sweep cannot carry + // the word BEFORE; an interval no longer than the shortest measured window can. The cost is a + // statfs per watched filesystem and one summary log line per run. + // The interval is NOT a cooldown and adds no repeat mails: the check is edge-triggered against + // PERSISTED bands with a hysteresis gap (fillwatch TestWarnsOnceThenIsSilent, + // TestThresholdsKeepTheirHysteresisGap), and the hub owns cooldown regardless. The 03:30 run is + // kept so the check still lands just before the nightly app-data legs. Pinned by + // TestFillWatchRunsOnAnInterval. fillWatcher := fillwatch.New( filepath.Join(cfg.Paths.DataDir, "fillwatch-state.json"), logger, func() []fillwatch.Target { return fillTargets(cfg, sett) }, @@ -1544,6 +1549,7 @@ func main() { // space the household can free on the kept-data list. Nothing is deleted by the box (D3). fillWatcher.SetExtra(func(t fillwatch.Target) string { return keptSpaceSentence(stackMgr, t.Path) }) sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { return fillWatcher.Check() }) + sched.Every("fill-watch-interval", fillWatchInterval, func(ctx context.Context) error { return fillWatcher.Check() }) // AND ONCE SHORTLY AFTER STARTUP. A box that BOOTS with a filesystem already over the line must // warn now, not up to 24 hours later — that is the R-100 shape, a real fault visible only after a @@ -2437,6 +2443,11 @@ func (s gatedAppStopStarter) StartStack(name string) error { // as unreadable (and be skipped, §8.4), never as a filesystem worth warning about. const fillWatchStartupDelay = 90 * time.Second +// fillWatchInterval is how often the fill check runs between the daily sweeps (R-363, R-547). It must +// not exceed the shortest fill window that was measured going unannounced (ten minutes, R-547): any +// window at least this long then contains a check. Pinned by TestFillWatchRunsOnAnInterval. +const fillWatchInterval = 10 * time.Minute + // fillTargets is §8.1's watch list: the app-data volume, the system-data volume, and every // registered drive. Resolved at CHECK time, not at startup, so a drive added or decommissioned // between checks is picked up without a controller restart. diff --git a/controller/cmd/controller/r208_dockerfile_args_test.go b/controller/cmd/controller/r208_dockerfile_args_test.go new file mode 100644 index 0000000..6c34423 --- /dev/null +++ b/controller/cmd/controller/r208_dockerfile_args_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// R-208: `ARG VERSION` / `ARG GIT_COMMIT` declared ABOVE `RUN go mod download` put a per-build value +// into that RUN's cache key, so every release re-downloaded the modules (measured: 208 cache records, +// every one with usage count 1). The consequence pinned here, in the builder stage: no ARG whose value +// changes per build is declared before the module-download step, and both are still declared before +// the `go build` that reads them (or the binary would report "dev"/"unknown"). +func TestR208_DockerfileVersionArgsSitBelowModuleDownload(t *testing.T) { + src, err := os.ReadFile("../../Dockerfile") + if err != nil { + t.Fatal(err) + } + lines := strings.Split(string(src), "\n") + argRe := regexp.MustCompile(`^\s*ARG\s+(VERSION|GIT_COMMIT|BUILD_TIME)\b`) + download, build := -1, -1 + args := map[string]int{} + for i, l := range lines { + if strings.HasPrefix(strings.TrimSpace(l), "FROM ") && i > 0 && download >= 0 && build >= 0 { + break // the runtime stage: only the builder matters + } + if m := argRe.FindStringSubmatch(l); m != nil { + if _, seen := args[m[1]]; !seen { + args[m[1]] = i // the FIRST declaration: any one above the download is the defect + } + } + if strings.Contains(l, "go mod download") && download < 0 { + download = i + } + if strings.Contains(l, "go build") && build < 0 { + build = i + } + } + if download < 0 || build < 0 { + t.Fatalf("the builder stage no longer has a `go mod download` (%d) or a `go build` (%d) — this test reads the wrong file", download, build) + } + for _, name := range []string{"VERSION", "GIT_COMMIT"} { + at, ok := args[name] + if !ok { + t.Errorf("ARG %s is not declared in the builder stage — the binary would be built without it", name) + continue + } + if at < download { + t.Errorf("R-208: ARG %s (line %d) is declared above `go mod download` (line %d), so every build with a new value re-downloads the modules", name, at+1, download+1) + } + if at > build { + t.Errorf("ARG %s (line %d) is declared after the `go build` that reads it (line %d)", name, at+1, build+1) + } + } +} diff --git a/controller/cmd/controller/r363_fillwatch_interval_test.go b/controller/cmd/controller/r363_fillwatch_interval_test.go new file mode 100644 index 0000000..446e793 --- /dev/null +++ b/controller/cmd/controller/r363_fillwatch_interval_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" + "time" +) + +// R-363 / R-547: the fill watcher ran once a day (plus once after start), so a filesystem that filled +// right after the 03:30 run went unannounced for ~24 h, and a disk held at 96 % for ten minutes raised +// nothing. The consequence pinned here: main.go registers the SAME fillWatcher.Check on a periodic +// job whose interval is no longer than the shortest measured silent window (10 min), so any such +// window contains a check. Edge-triggering (no repeat mails at a faster cadence) is pinned in +// internal/fillwatch (TestWarnsOnceThenIsSilent, TestEdgeStateSurvivesARestart). +func TestFillWatchRunsOnAnInterval(t *testing.T) { + const measuredSilentWindow = 10 * time.Minute // R-547, chaos night 2026-09-17 + if fillWatchInterval <= 0 || fillWatchInterval > measuredSilentWindow { + t.Errorf("fillWatchInterval = %s; it must be >0 and <= %s, or a fill window like R-547's can pass unseen", fillWatchInterval, measuredSilentWindow) + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + periodic := 0 + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Every" || len(call.Args) != 3 { + return true + } + iv, ok := call.Args[1].(*ast.Ident) + if !ok || iv.Name != "fillWatchInterval" { + return true + } + // The job body must call fillWatcher.Check — the same edge-triggered check, not a new one. + ast.Inspect(call.Args[2], func(m ast.Node) bool { + if c, ok := m.(*ast.CallExpr); ok { + if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Check" { + if x, ok := s.X.(*ast.Ident); ok && x.Name == "fillWatcher" { + periodic++ + } + } + } + return true + }) + return true + }) + if periodic != 1 { + t.Errorf("R-363: main.go registers fillWatcher.Check on a periodic (sched.Every, fillWatchInterval) job %d times, want 1 — without it the fill check is daily only", periodic) + } +} diff --git a/controller/internal/agentapi/diskverdict.go b/controller/internal/agentapi/diskverdict.go index d5551cf..fc2912e 100644 --- a/controller/internal/agentapi/diskverdict.go +++ b/controller/internal/agentapi/diskverdict.go @@ -57,20 +57,20 @@ type DiskPrior struct { // DiskVerdictFor maps a SmartSummary plus the previous observation to a verdict. Rules are evaluated // TOP-DOWN and the FIRST match wins (v0.215.0): // -// 1. nil / "" / UNKNOWN -> Nincs adat -// 2. Health == FAILING -> Hiba (drive self-reports) -// 3. temperature_c >= 60 -> Hiba -// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba -// 5. percentage_used >= 100 -> Hiba -// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED) -// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping) -// 8. unreadable >= 64 -> Hiba (too large to be a blip) -// 9. unreadable > 0 -> Figyelmeztetés (first sighting) -// 10. reallocated > 0 -> Figyelmeztetés -// 11. media_errors > 0 -> Figyelmeztetés -// 12. percentage_used >= 90 -> Figyelmeztetés -// 13. temperature_c >= 55 -> Figyelmeztetés -// 14. otherwise -> Rendben +// 1. nil / "" / UNKNOWN -> Nincs adat +// 2. Health == FAILING -> Hiba (drive self-reports) +// 3. temperature_c >= 60 -> Hiba +// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba +// 5. percentage_used >= 100 -> Hiba +// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED) +// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping) +// 8. unreadable >= 64 -> Hiba (too large to be a blip) +// 9. unreadable > 0 -> Figyelmeztetés (first sighting) +// 10. reallocated > 0 -> Figyelmeztetés +// 11. media_errors > 0 -> Figyelmeztetés +// 12. percentage_used >= 90 -> Figyelmeztetés +// 13. temperature_c >= 55 -> Figyelmeztetés +// 14. otherwise -> Rendben // // WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187, // 197 and 198 all carry thresh 0, and a normalized SMART value floors at 1, so it can never drop to diff --git a/controller/internal/api/r552_remove_clears_notice_test.go b/controller/internal/api/r552_remove_clears_notice_test.go new file mode 100644 index 0000000..0db4379 --- /dev/null +++ b/controller/internal/api/r552_remove_clears_notice_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "io" + "log" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-552: an interrupted-restore notice was cleared only by a NEW restore of the same app, so a +// household that answered it by REMOVING the app kept a „Megszakadt visszaállítás" card about an app +// that no longer exists — for ever, because the notice is persisted. The consequence asserted: after +// the removal path's clear, the notice is gone from the page's list AND from the persisted record (a +// fresh Manager loading the file sees none), while another app's notice stays. +// COMPANION RED-PROOF: drop the clearInterruptedRestoreNotice call from removeStack → the AST check +// fails; make ClearInterruptedRestore a no-op → the notice is still listed. +func TestR552_RemoveClearsTheInterruptedRestoreNotice(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + cfg.Paths.DataDir = dir + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + recPath := filepath.Join(dir, "restore-status.json") + // The record two stops left behind: "otherapp" already carries a notice, "homebox" was running. + seed := map[string]any{ + "running": true, "op": "restore", "stack": "homebox", + "interrupted": map[string]any{"otherapp": map[string]any{"op": "restore", "stack": "otherapp", "interrupted": true}}, + } + b, _ := json.Marshal(seed) + if err := os.WriteFile(recPath, b, 0o600); err != nil { + t.Fatal(err) + } + load := func() *backup.Manager { + bm := backup.NewManager(cfg, sett, lg) + bm.SetRestoreRecordPath(recPath) + bm.LoadRestoreRecord() + return bm + } + bm := load() + if _, ok := bm.InterruptedRestore("homebox"); !ok { + t.Fatal("setup: no interrupted notice for homebox") + } + + r := &Router{backupMgr: bm, logger: lg} + r.clearInterruptedRestoreNotice("homebox") + + if _, ok := bm.InterruptedRestore("homebox"); ok { + t.Errorf("R-552: the removed app's interrupted-restore notice is still listed") + } + if _, ok := bm.InterruptedRestore("otherapp"); !ok { + t.Errorf("another app's notice was cleared by this app's removal") + } + again := load() + if _, ok := again.InterruptedRestore("homebox"); ok { + t.Errorf("R-552: the notice comes back after a restart — the clear was not persisted") + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "router.go", nil, 0) + if err != nil { + t.Fatal(err) + } + called := false + for _, d := range f.Decls { + if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil { + ast.Inspect(fn.Body, func(n ast.Node) bool { + if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "clearInterruptedRestoreNotice" { + called = true + } + return true + }) + } + } + if !called { + t.Errorf("R-552: removeStack does not call clearInterruptedRestoreNotice") + } +} diff --git a/controller/internal/api/r619_password_required_test.go b/controller/internal/api/r619_password_required_test.go new file mode 100644 index 0000000..e3c20cc --- /dev/null +++ b/controller/internal/api/r619_password_required_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-619: GET /api/stacks//deploy-fields served a `type: password` field as `required:false` (as the +// template declares), while the deploy refuses 400 without it — so a caller that trusted the contract +// was refused (measured on grafana, 2026-09-21). The consequence asserted, on the wire through the real +// handler: the password field arrives `required:true`; a `secret` field (which the box DOES generate) +// keeps its declared `required:false`; and the stack's own metadata is not changed for the next reader. +func TestR619_PasswordFieldIsServedAsRequired(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Stacks.ComposeCommand = "docker compose" + app := filepath.Join(cfg.Paths.StacksDir, "grafana") + if err := os.MkdirAll(app, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n grafana:\n image: busybox\n"), 0o644) + _ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(`display_name: Grafana +deploy_fields: + - env_var: GF_SECURITY_ADMIN_PASSWORD + label: Admin jelszo + type: password + generate: "password:16" + required: false + - env_var: GF_SECRET_KEY + label: Titkos kulcs + type: secret + generate: "hex:32" + required: false +`), 0o644) + m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := m.ScanStacks(); err != nil { + t.Fatal(err) + } + r := &Router{stackMgr: m, cfg: cfg, logger: log.New(io.Discard, "", 0)} + + read := func() map[string]bool { + w := httptest.NewRecorder() + r.getDeployFields(w, httptest.NewRequest(http.MethodGet, "/api/stacks/grafana/deploy-fields", nil), "grafana") + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var body struct { + Data struct { + Metadata struct { + DeployFields []struct { + EnvVar string `json:"env_var"` + Required bool `json:"required"` + } `json:"deploy_fields"` + } `json:"metadata"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + out := map[string]bool{} + for _, f := range body.Data.Metadata.DeployFields { + out[f.EnvVar] = f.Required + } + return out + } + got := read() + if !got["GF_SECURITY_ADMIN_PASSWORD"] { + t.Errorf("R-619: the password field reaches the wire as required:false, but the deploy refuses without it") + } + if req, ok := got["GF_SECRET_KEY"]; !ok || req { + t.Errorf("a secret field (the box generates it) must keep its declared required:false; got present=%v required=%v", ok, req) + } + if meta, _, _ := m.GetDeployFields("grafana"); meta.DeployFields[0].Required { + t.Errorf("the derivation leaked into the stack's metadata — it must be applied to the answer only") + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index d03dc4b..6b2a21b 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -391,6 +391,23 @@ func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string) writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack}) } +// markPasswordFieldsRequired (R-619) serves the deploy refusal's real rule on the wire: a +// `type: password` field is MANDATORY however the template's `required` reads, because the deploy +// never generates a password the household has not seen (stacks/deploy.go, the "password" case). +// Served `required:false`, a caller that trusts the contract was refused 400. Derived here, not stored, +// so templates need no edit. meta is the handler's own fresh LoadMetadata copy, so changing it touches +// no shared state. Pinned by TestR619_PasswordFieldIsServedAsRequired. +func markPasswordFieldsRequired(meta *stacks.Metadata) { + if meta == nil { + return + } + for i := range meta.DeployFields { + if meta.DeployFields[i].Type == "password" { + meta.DeployFields[i].Required = true + } + } +} + func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) { meta, appCfg, err := r.stackMgr.GetDeployFields(name) if err != nil { @@ -398,6 +415,7 @@ func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name return } + markPasswordFieldsRequired(meta) data := map[string]interface{}{ "metadata": meta, "app_config": appCfg, @@ -929,6 +947,18 @@ func (r *Router) dropLeftoverHold(name, why string) { } } +// clearInterruptedRestoreNotice (R-552) drops the removed app's „Megszakadt visszaállítás" notice. The +// notice tells the household to run the restore again; once the app is removed that advice has no +// subject, and nothing else would ever clear it. Pinned by TestR552_RemoveClearsTheInterruptedRestoreNotice. +func (r *Router) clearInterruptedRestoreNotice(name string) { + if r.backupMgr == nil { + return + } + if r.backupMgr.ClearInterruptedRestore(name) { + r.logger.Printf("[INFO] [api] remove %s: its interrupted-restore notice is cleared with it (R-552)", name) + } +} + // removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy // (`backups/offsite-restore/`, left by a full off-site restore for the household to inspect) when the // app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and @@ -1057,6 +1087,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name) } } + r.clearInterruptedRestoreNotice(name) writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"}) diff --git a/controller/internal/api/update_reason_test.go b/controller/internal/api/update_reason_test.go index b8eaaaf..d30b7af 100644 --- a/controller/internal/api/update_reason_test.go +++ b/controller/internal/api/update_reason_test.go @@ -87,8 +87,8 @@ func TestR609_EveryRefusalCarriesItsReason(t *testing.T) { wantReason: "downgrade", wantCode: http.StatusConflict, }, { - name: "not_found — an app that exists nowhere", - arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {}, + name: "not_found — an app that exists nowhere", + arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {}, wantReason: "not_found", wantCode: http.StatusNotFound, }, } diff --git a/controller/internal/appbackup/dbdump.go b/controller/internal/appbackup/dbdump.go index 9e43b2c..a59dfae 100644 --- a/controller/internal/appbackup/dbdump.go +++ b/controller/internal/appbackup/dbdump.go @@ -228,6 +228,17 @@ func DumpAll(ctx context.Context, dbs []DiscoveredDB, dumpDir string, logger *lo return results } +// syncDumpDir fsyncs a directory so a rename inside it survives a power cut (R-10). A variable so a +// test can observe the call; production never replaces it. +var syncDumpDir = func(dir string) error { + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + // CanonicalDumpName is the app's own dump filename for one database: `-.sql`. It is // the name the replay loop matches EXACTLY, which is why nothing else may ever be written to it. func CanonicalDumpName(db DiscoveredDB) string { @@ -393,6 +404,14 @@ func DumpOneTo(ctx context.Context, db DiscoveredDB, finalPath string, logger *l result.Duration = time.Since(start) return result } + // R-10 (T-6E-1): the rename is durable only once the DIRECTORY entry is on disk. Without this a + // power cut right after the rename can leave the old dump (or none) under the final name, even + // though the data itself was fsynced above. Best-effort, like atomicPromoteTar's dir.Sync() in + // internal/backup/backup.go — the dump is already complete, so a failed dir sync is logged, not + // fatal. Pinned by TestDumpOneTo_SyncsTheDumpDirectoryAfterRename. + if err := syncDumpDir(dumpDir); err != nil { + logger.Printf("[DEBUG] DumpOne: syncing dump directory %s after rename failed (best-effort): %v", dumpDir, err) + } result.FilePath = finalPath result.Size = stat.Size() diff --git a/controller/internal/appbackup/namespace_root_test.go b/controller/internal/appbackup/namespace_root_test.go index 1225d09..03f17aa 100644 --- a/controller/internal/appbackup/namespace_root_test.go +++ b/controller/internal/appbackup/namespace_root_test.go @@ -56,7 +56,7 @@ func TestAppBindAndCaptureRootAgree(t *testing.T) { const sys = "/mnt/sys_drive" for _, drive := range []string{"/mnt/felhom-usb", "/mnt/felhom-drives/hdd_1", "/mnt/sys_drive"} { nsRoot := NamespaceRootFor(drive, sys) - appBind := UserdataDir(nsRoot) // what the deploy sets as ${USERDATA_PATH} + appBind := UserdataDir(nsRoot) // what the deploy sets as ${USERDATA_PATH} captureRoot := UserdataDir(nsRoot) // what the capture set resolves RootUserdata against if appBind != captureRoot { t.Fatalf("drive %q: the app binds %q while the backup captures %q", drive, appBind, captureRoot) diff --git a/controller/internal/appbackup/r10_dump_dirsync_test.go b/controller/internal/appbackup/r10_dump_dirsync_test.go new file mode 100644 index 0000000..3be8d3f --- /dev/null +++ b/controller/internal/appbackup/r10_dump_dirsync_test.go @@ -0,0 +1,56 @@ +package appbackup + +import ( + "context" + "io" + "log" + "os" + "path/filepath" + "testing" +) + +// R-10 (T-6E-1): DumpOneTo fsynced the dump FILE and renamed it, but never fsynced the DIRECTORY, so +// the rename itself was not durable — the asymmetry with atomicPromoteTar (backup.go), which does. +// The consequence asserted: a successful dump syncs the directory that holds the final file, AFTER +// the final name exists (the sync must cover the rename, not precede it). +// +// No real docker: a `docker` stub in t.TempDir() on PATH (R-650's sanctioned seam) answers the +// running-check and prints a small SQL dump. +func TestDumpOneTo_SyncsTheDumpDirectoryAfterRename(t *testing.T) { + bin := t.TempDir() + stub := "#!/bin/sh\ncase \"$1\" in\n inspect) echo true ;;\n exec) printf 'CREATE TABLE t (id int);\\nINSERT INTO t VALUES (1);\\n' ;;\nesac\n" + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + + dumpDir := filepath.Join(t.TempDir(), "unit") + final := filepath.Join(dumpDir, "app-postgres.sql") + + var synced []string + finalExistedAtSync := false + orig := syncDumpDir + syncDumpDir = func(dir string) error { + synced = append(synced, dir) + if _, err := os.Stat(final); err == nil { + finalExistedAtSync = true + } + return orig(dir) + } + t.Cleanup(func() { syncDumpDir = orig }) + + db := DiscoveredDB{ContainerName: "app-db", ContainerID: "0123456789abcdef", DBType: DBTypePostgres, DBUser: "u", DBName: "d", StackName: "app"} + res := DumpOneTo(context.Background(), db, final, log.New(io.Discard, "", 0), false) + if res.Error != nil { + t.Fatalf("dump failed: %v", res.Error) + } + if _, err := os.Stat(final + ".tmp"); !os.IsNotExist(err) { + t.Errorf("the .tmp scratch file is still present after a successful dump") + } + if len(synced) != 1 || synced[0] != dumpDir { + t.Fatalf("R-10: the dump directory was not fsynced after the rename: synced=%v, want [%s]", synced, dumpDir) + } + if !finalExistedAtSync { + t.Errorf("R-10: the directory was synced BEFORE the final name existed — the rename is not covered") + } +} diff --git a/controller/internal/appbackup/r381_undo_naming_test.go b/controller/internal/appbackup/r381_undo_naming_test.go index 32a7b69..6d49d14 100644 --- a/controller/internal/appbackup/r381_undo_naming_test.go +++ b/controller/internal/appbackup/r381_undo_naming_test.go @@ -190,9 +190,9 @@ func TestImportDumpErrorDoesNotCarryEngineStderr(t *testing.T) { // test; what CAN be pinned is the thing the mutation changes. // // TWO PROPERTIES, both mutation-detectable: -// 1. `finalPath` is never reassigned — the caller's destination is the destination. -// 2. the scratch file is derived from `finalPath`, not from the canonical name, so a nightly dump -// and a safety dump running into the same directory cannot share it. +// 1. `finalPath` is never reassigned — the caller's destination is the destination. +// 2. the scratch file is derived from `finalPath`, not from the canonical name, so a nightly dump +// and a safety dump running into the same directory cannot share it. func TestDumpOneToHonoursTheDestinationItWasGiven(t *testing.T) { fset := token.NewFileSet() f, err := parser.ParseFile(fset, "dbdump.go", nil, 0) diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 85c7dce..30910a4 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -155,6 +155,9 @@ type Manager struct { // offboxFreeFn (3a) — the free-space probe for the restore headroom gate, overridable in tests (the // Windows `go test` host has no `df`). Nil → the real diskFreeBytes (df --output=avail). offboxFreeFn func(path string) int64 + // driveMountedFn (R-362) answers "is this registered drive still mounted?" when a restore cannot + // create its directory. nil → system.IsMountPoint. Tests inject it; production never sets it. + driveMountedFn func(path string) bool // offboxLatestSnapFn (R-357) overrides the restic snapshot lookup, and it exists for one reason: // without it, ReconstituteFromOffsite's new headroom gate cannot be tested at the level that diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 46bef1e..1c27ce4 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -148,8 +148,17 @@ const ( OffsiteFailNoUnits OffsiteFailureClass = "no_units" // apps toggled but no recovery unit found on any drive OffsiteFailTransport OffsiteFailureClass = "transport" // network / SFTP auth / host key / timeout OffsiteFailUnknown OffsiteFailureClass = "unknown" // genuinely unclassified — say so rather than guess + // OffsiteFailLocked (R-104): the repository is held by a lock that survived both self-heal layers + // (pre-run stale unlock, then resticStep's unlock --remove-all + one retry). Known and named, not + // "unknown" — an interrupted earlier run is the usual cause. + OffsiteFailLocked OffsiteFailureClass = "locked" ) +// ErrOffsiteLocked marks a restic step that still found the repository locked after resticStep's +// self-heal (R-104). resticStep wraps it into the step's error, because the lock text lives in the +// command OUTPUT, not in the exit error, and would never reach ClassifyOffsiteFailure otherwise. +var ErrOffsiteLocked = errors.New("offsite repository is still locked after the self-heal") + // offsiteRepoURLRe matches the `sftp:user@host:/path` repo reference restic echoes back in its errors. // It is the BACKSTOP, not the primary defence — see sanitiseOffsiteErrorFor. var offsiteRepoURLRe = regexp.MustCompile(`sftp:[^\s"']+`) @@ -203,6 +212,12 @@ func ClassifyOffsiteFailure(err error) OffsiteFailureClass { if errors.Is(err, ErrOffsiteQuota) { return OffsiteFailQuota } + // R-104 — before the transport signatures: restic's lock error is a precisely known cause. The + // sentinel is what resticStep attaches; the text match catches an error that carries restic's + // own output. Pinned by TestR104_SurvivingLockIsNamed. + if errors.Is(err, ErrOffsiteLocked) || offboxLockRe.MatchString(err.Error()) { + return OffsiteFailLocked + } s := strings.ToLower(err.Error()) switch { case strings.Contains(s, "produced no snapshots"): @@ -248,6 +263,7 @@ func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duratio OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"), OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"), OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"), + OffsiteFailLocked: util.Text(lang, "note.offsite.fail_locked"), }[ClassifyOffsiteFailure(err)] if head == "" { head = util.Text(lang, "note.offsite.fail_head") @@ -855,10 +871,14 @@ func (m *Manager) resticStep(ctx context.Context, env, base []string, label stri if uout, uerr := m.runner()(uctx, env, append(append([]string{}, base...), "unlock", "--remove-all")...); uerr != nil { cancel() m.logger.Printf("[WARN] [offbox] unlock --remove-all failed: %v: %s", uerr, truncate(uout)) - return out, err // surface the original lock error (never loop) + return out, fmt.Errorf("%w (%w)", err, ErrOffsiteLocked) // surface the original lock error (never loop); R-104: named } cancel() - return m.runner()(ctx, env, full...) // retry exactly ONCE + rout, rerr := m.runner()(ctx, env, full...) // retry exactly ONCE + if rerr != nil && offboxLockRe.Match(rout) { + return rout, fmt.Errorf("%w (%w)", rerr, ErrOffsiteLocked) // R-104: still locked after the self-heal + } + return rout, rerr } // ensureOffboxRepo makes sure the SFTP repo exists: probe `cat config`; if absent, `init` (idempotent — @@ -1164,8 +1184,13 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error // is covered until at least one app is toggled. // R-7b: the shares leg counts as coverage — a box whose only cloud content is its shares // must not be told "nothing is selected". + // R-240: the sentence no longer opens with „Sikeres" — a run that covered nothing was + // congratulating the household on it. The verdict stays `ok` (an unconfigured selection is + // not a failure). Still a Hungarian LITERAL and still carrying the lowercase marker + // „nincs mentésre jelölt alkalmazás" (R-570: boxes with the older persisted text are + // recognised by those words). Pinned by TestR240_ZeroSelectionRunDoesNotSaySuccess. if len(apps) == 0 && !runResult.sharesBackedUp { - warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás") + warns = append(warns, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás") warnKind = OffboxWarnNoAppsSelected // R-553: the page reads this, not the sentence } // R-234 §7.4 — WHICH apps, WHY, and WHEN. The old sentence said only that N apps "had no @@ -1926,7 +1951,7 @@ func (m *Manager) RestoreOffbox(ctx context.Context, stackName, destDir string) return fmt.Errorf("invalid stack name") } if err := os.MkdirAll(destDir, 0o755); err != nil { - return fmt.Errorf("restore dir: %w", err) + return m.restoreDirError(destDir, err) } t := m.settings.GetOffboxTarget() base, env := m.offboxBaseArgs(t) diff --git a/controller/internal/backup/offbox_inventory.go b/controller/internal/backup/offbox_inventory.go index f6422a9..05fd682 100644 --- a/controller/internal/backup/offbox_inventory.go +++ b/controller/internal/backup/offbox_inventory.go @@ -61,6 +61,11 @@ type offsiteNewest struct { paths []string } +// OffboxMarkerTag is on EVERY off-site snapshot (`restic backup --tag felhom-offbox --tag `), +// beside the app's own tag. It marks the tier, it does not name an app, so no per-app view may key +// on it (R-251). Pinned by TestR251_MarkerTagIsNotAnApp. +const OffboxMarkerTag = "felhom-offbox" + // offsiteNewestPerTag runs ONE `snapshots --json` and returns the newest snapshot per app tag, and // whether the repository opened cleanly and holds no snapshots at all. Shared by the inventory page // and the update precondition (R-477), so the two cannot disagree about what is in the repository. @@ -100,7 +105,10 @@ func (m *Manager) offsiteNewestPerTag(ctx context.Context) (map[string]offsiteNe id = sn.ID } for _, tag := range sn.Tags { - if tag == "" { + if tag == "" || tag == OffboxMarkerTag { + // R-251: the marker is on EVERY off-site snapshot; it is not an app. Keyed here it + // became a second row on the recovery listing — a stranger's name beside the + // customer's app and their data counted twice. continue } if cur, ok := newest[tag]; !ok || sn.Time.After(cur.at) { diff --git a/controller/internal/backup/offbox_restore.go b/controller/internal/backup/offbox_restore.go index be616e4..bae639d 100644 --- a/controller/internal/backup/offbox_restore.go +++ b/controller/internal/backup/offbox_restore.go @@ -377,7 +377,7 @@ func (m *Manager) RestoreOffboxScratch(ctx context.Context, stack string, full b } } if err := os.MkdirAll(scratch, 0o755); err != nil { - return fmt.Errorf("restore dir: %w", err) + return m.restoreDirError(scratch, err) } // R-358: a marker from a PREVIOUS run must never certify this one. Cleared here, before restic // touches anything, so the window in which a stale certificate could vouch for a part-copy does not diff --git a/controller/internal/backup/offbox_test.go b/controller/internal/backup/offbox_test.go index 3debafa..044b7ea 100644 --- a/controller/internal/backup/offbox_test.go +++ b/controller/internal/backup/offbox_test.go @@ -195,7 +195,7 @@ func TestOffbox_RunFailsFastAndAlerts(t *testing.T) { } // Zero-toggle honesty (take-two obs., v0.123.0): a run with a configured+escrowed target but ZERO -// toggled apps must stay status=ok yet report "Sikeres — nincs mentésre jelölt alkalmazás" instead +// toggled apps must stay status=ok yet report "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás" instead // of a bare success. Red-proof: drop the len(apps)==0 warns branch → the LastWarning assertion fails. func TestOffbox_ZeroToggledRunReportsEmptiness(t *testing.T) { m, sett := newOffboxManager(t) @@ -213,7 +213,7 @@ func TestOffbox_ZeroToggledRunReportsEmptiness(t *testing.T) { if st.LastStatus != "ok" { t.Fatalf("zero-toggled run status = %q, want ok (emptiness is honesty, not failure)", st.LastStatus) } - if !strings.Contains(st.LastWarning, "Sikeres — nincs mentésre jelölt alkalmazás") { + if !strings.Contains(st.LastWarning, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás") { t.Fatalf("zero-toggled run must report its emptiness, LastWarning = %q", st.LastWarning) } diff --git a/controller/internal/backup/offsite_diag_test.go b/controller/internal/backup/offsite_diag_test.go index ac24675..46a99e2 100644 --- a/controller/internal/backup/offsite_diag_test.go +++ b/controller/internal/backup/offsite_diag_test.go @@ -168,7 +168,7 @@ func backupNoteHU(t *testing.T, key string) string { // // RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line. func TestR570SentenceStaysHungarian(t *testing.T) { - const sentence = "Sikeres — nincs mentésre jelölt alkalmazás" + const sentence = "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás" src, err := os.ReadFile("offbox.go") if err != nil { t.Fatal(err) diff --git a/controller/internal/backup/r104_lock_class_test.go b/controller/internal/backup/r104_lock_class_test.go new file mode 100644 index 0000000..c91de1c --- /dev/null +++ b/controller/internal/backup/r104_lock_class_test.go @@ -0,0 +1,55 @@ +package backup + +import ( + "context" + "fmt" + "strings" + "testing" + "time" +) + +// R-104: a restic lock that survives BOTH self-heal layers was reported as „ismeretlen okból" — +// the class had no lock case, and the lock text lives in restic's OUTPUT, never in the exit error, +// so the text classifier could not have seen it anyway. The consequence asserted, through the real +// resticStep with a scripted runner: the failure the run would report is classed Locked, and the +// message names the lock in both languages instead of admitting an unknown cause. +func TestR104_SurvivingLockIsNamed(t *testing.T) { + m, _ := newOffboxManager(t) + locked := []byte("Fatal: unable to create lock in backend: repository is already locked exclusively by PID 77 on felhom-controller\n") + calls := 0 + m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { + calls++ + if contains(args, "unlock") { + return nil, nil // the remove-all "succeeds", and the lock is still there on the retry + } + return locked, fmt.Errorf("exit status 1") + }) + _, err := m.resticStep(context.Background(), nil, []string{"-r", "repo"}, "backup:app", "backup", "/x") + if err == nil { + t.Fatal("setup: the scripted lock did not fail the step") + } + if calls != 3 { + t.Errorf("setup: want step + unlock --remove-all + one retry (3 calls), got %d", calls) + } + runErr := fmt.Errorf("offbox backup app: %w", err) // exactly how the run wraps a step failure + if got := ClassifyOffsiteFailure(runErr); got != OffsiteFailLocked { + t.Errorf("R-104: a lock that survived the self-heal is classed %q, want %q", got, OffsiteFailLocked) + } + hu := offsiteFailureMessage(diagTarget(), runErr, time.Minute, "hu") + if strings.Contains(hu, "ismeretlen okb") || !strings.Contains(hu, "zárva hagyta") { + t.Errorf("R-104: the Hungarian message does not name the lock: %q", hu) + } + en := offsiteFailureMessage(diagTarget(), runErr, time.Minute, "en") + if strings.Contains(en, "unknown reason") || !strings.Contains(en, "still locked") { + t.Errorf("R-104: the English message does not name the lock: %q", en) + } + + // The text path: an error that carries restic's own output is named too. + if got := ClassifyOffsiteFailure(fmt.Errorf("restic: %s", locked)); got != OffsiteFailLocked { + t.Errorf("R-104: restic's lock text is classed %q, want %q", got, OffsiteFailLocked) + } + // Negative control: an unrelated failure is still honest about being unknown. + if got := ClassifyOffsiteFailure(fmt.Errorf("something entirely new")); got != OffsiteFailUnknown { + t.Errorf("an unrelated failure became %q", got) + } +} diff --git a/controller/internal/backup/r240_zero_selection_test.go b/controller/internal/backup/r240_zero_selection_test.go new file mode 100644 index 0000000..30c61de --- /dev/null +++ b/controller/internal/backup/r240_zero_selection_test.go @@ -0,0 +1,41 @@ +package backup + +import ( + "context" + "strings" + "testing" +) + +// R-240: an off-site run with no app selected reported „Sikeres — nincs mentésre jelölt alkalmazás": +// SUCCESSFUL right beside NOTHING SELECTED, the same shape as R-203/R-234 (a warning beside a success +// is read as a success). The consequence asserted through the real run: the verdict stays `ok` (an +// empty selection is not a failure), the note says plainly that the run saved nothing, it does NOT +// say „Sikeres", and it still carries the lowercase marker the R-570 legacy fallback reads. +func TestR240_ZeroSelectionRunDoesNotSaySuccess(t *testing.T) { + m, sett := newOffboxManager(t) + m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { + if contains(args, "cat") && contains(args, "config") { + return []byte(`{"version":2}`), nil + } + return []byte(`[]`), nil + }) + if err := m.RunOffboxBackup(context.Background()); err != nil { + t.Fatalf("zero-selection run must not error: %v", err) + } + st := sett.GetOffboxTarget() + if st.LastStatus != "ok" { + t.Errorf("verdict = %q, want ok — an empty selection is not a failure", st.LastStatus) + } + if strings.Contains(st.LastWarning, "Sikeres") { + t.Errorf("R-240: the note for a run that saved nothing still calls itself successful: %q", st.LastWarning) + } + if !strings.Contains(st.LastWarning, "semmit nem mentett") { + t.Errorf("R-240: the note does not say the run saved nothing: %q", st.LastWarning) + } + if !strings.Contains(st.LastWarning, "nincs mentésre jelölt alkalmazás") { + t.Errorf("the R-570 marker is gone from the note: %q", st.LastWarning) + } + if st.LastWarningKind != OffboxWarnNoAppsSelected { + t.Errorf("the kind is not recorded: %q", st.LastWarningKind) + } +} diff --git a/controller/internal/backup/r251_marker_tag_test.go b/controller/internal/backup/r251_marker_tag_test.go new file mode 100644 index 0000000..62c9ad1 --- /dev/null +++ b/controller/internal/backup/r251_marker_tag_test.go @@ -0,0 +1,61 @@ +package backup + +import ( + "context" + "sync/atomic" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-251: every off-site snapshot carries the tier's marker tag beside the app's own. The recovery +// listing keyed rows on every tag, so the customer saw TWO rows for one app — `calibre-web` and +// `felhom-offbox`, each 12.8 MB — a stranger's name and their data counted twice. The consequence +// asserted: the listing holds exactly the app, and only ONE size call is spent (the marker costs no +// round-trip to the storage box). +func TestR251_MarkerTagIsNotAnApp(t *testing.T) { + m, sett := newOffboxManager(t) + if err := sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", + Schedule: "daily", EscrowState: "escrowed", + }); err != nil { + t.Fatal(err) + } + if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { + t.Fatal(err) + } + var stats int32 + m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { + if contains(args, "snapshots") { + return []byte(`[{"short_id":"snap0","time":"2026-08-07T14:57:00Z","tags":["felhom-offbox","calibre-web"]}]`), nil + } + if contains(args, "stats") { + atomic.AddInt32(&stats, 1) + return []byte(`{"total_size":13421772}`), nil + } + return nil, nil + }) + + inv, err := m.OffsiteInventoryList(context.Background()) + if err != nil { + t.Fatal(err) + } + var names []string + for _, a := range inv.Apps { + names = append(names, a.App) + } + if len(names) != 1 || names[0] != "calibre-web" { + t.Errorf("R-251: the recovery listing shows %v; want only [calibre-web] — the marker tag is not an app", names) + } + if n := atomic.LoadInt32(&stats); n != 1 { + t.Errorf("R-251: %d size calls for one app, want 1", n) + } + + times, err := m.OffsiteSnapshotTimes(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, ok := times[OffboxMarkerTag]; ok { + t.Errorf("R-251: the marker tag is reported as an app with a snapshot time") + } +} diff --git a/controller/internal/backup/r362_restore_drive_gone_test.go b/controller/internal/backup/r362_restore_drive_gone_test.go new file mode 100644 index 0000000..f697a2b --- /dev/null +++ b/controller/internal/backup/r362_restore_drive_gone_test.go @@ -0,0 +1,77 @@ +package backup + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" +) + +// R-362: a data drive unbound 4 s into a scratch restore was reported to the household as +// „restore dir: mkdir /mnt/felhom-drives/hdd_1/backups: permission denied" — a correct refusal that +// misdescribed why. The consequence asserted: what the household reads (util.ErrText, the renderer +// the restore notice uses) names the DRIVE, in both languages, and no longer says "permission +// denied" — while a genuine permission problem on a drive that IS mounted keeps its own wording. +func TestR362_DetachedDriveIsNamed(t *testing.T) { + m, sett := newOffboxManager(t) + managed := filepath.Join(settings.NetworkMountRoot, "hdd_1") + legacy := "/mnt/hdd_legacy" + for _, sp := range []settings.StoragePath{ + {Path: managed, Label: "Kulso HDD"}, + {Path: legacy, Label: "Regi HDD", Disconnected: true}, + } { + if err := sett.AddStoragePath(sp); err != nil { + t.Fatal(err) + } + } + mounted := false + m.driveMountedFn = func(string) bool { return mounted } + eacces := func(p string) error { return &fs.PathError{Op: "mkdir", Path: p, Err: syscall.EACCES} } + scratch := filepath.Join(managed, "backups", "offsite-restore", "app") + + // 1. The managed drive is no longer a mount point (the flag has not caught up yet). + err := m.restoreDirError(scratch, eacces(filepath.Join(managed, "backups"))) + hu, en := util.ErrText("hu", err), util.ErrText("en", err) + if strings.Contains(hu, "permission denied") || !strings.Contains(hu, "Kulso HDD") || !strings.Contains(hu, "nem érhető el") { + t.Errorf("R-362: the Hungarian refusal does not name the missing drive: %q", hu) + } + if strings.Contains(en, "permission denied") || !strings.Contains(en, "Kulso HDD") || !strings.Contains(en, "cannot be reached") { + t.Errorf("R-362: the English refusal does not name the missing drive: %q", en) + } + + // 2. A legacy path outside the managed parent, flagged disconnected by the drive gate. + if got := util.ErrText("en", m.restoreDirError(filepath.Join(legacy, "x"), eacces(legacy))); !strings.Contains(got, "Regi HDD") { + t.Errorf("R-362: a drive the registry marks disconnected is not named: %q", got) + } + + // 3. Negative control: the drive IS mounted, so a permission error is a real permission error. + mounted = true + if got := util.ErrText("en", m.restoreDirError(scratch, eacces(scratch))); !strings.Contains(got, "permission denied") { + t.Errorf("a genuine permission error on a mounted drive must keep its wording, got %q", got) + } + // 4. Negative control: a different failure class is never re-labelled. + mounted = false + if got := util.ErrText("en", m.restoreDirError(scratch, &fs.PathError{Op: "mkdir", Path: scratch, Err: syscall.EIO})); strings.Contains(got, "cannot be reached") { + t.Errorf("an I/O error was re-labelled as a missing drive: %q", got) + } + + // Wiring: every restore that creates its directory goes through restoreDirError — no bare + // "restore dir: %w" is left at a call site. + for _, f := range []string{"offbox_restore.go", "offbox.go", "shares_restore.go"} { + src, rerr := os.ReadFile(f) + if rerr != nil { + t.Fatal(rerr) + } + if strings.Contains(string(src), `fmt.Errorf("restore dir: %w", err)`) { + t.Errorf("R-362: %s still returns the raw restore-dir error", f) + } + if !strings.Contains(string(src), "m.restoreDirError(") { + t.Errorf("R-362: %s does not call restoreDirError", f) + } + } +} diff --git a/controller/internal/backup/r553_offsite_quota_test.go b/controller/internal/backup/r553_offsite_quota_test.go index 245af92..bc720b0 100644 --- a/controller/internal/backup/r553_offsite_quota_test.go +++ b/controller/internal/backup/r553_offsite_quota_test.go @@ -81,7 +81,7 @@ func TestR553_OffboxRunRecordsTheKind(t *testing.T) { t.Fatal(err) } body := string(src) - i := strings.Index(body, `warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")`) + i := strings.Index(body, `warns = append(warns, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás")`) if i < 0 { t.Fatal("the zero-selection sentence is gone from the run — this test no longer reads what it thinks it reads") } diff --git a/controller/internal/backup/r669_applied_meta_test.go b/controller/internal/backup/r669_applied_meta_test.go index 88a1529..4718c8c 100644 --- a/controller/internal/backup/r669_applied_meta_test.go +++ b/controller/internal/backup/r669_applied_meta_test.go @@ -24,7 +24,7 @@ func TestR669_UnitCapturesThePinnedVersionsMeta(t *testing.T) { drive := filepath.Join(tmp, "drive") mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: example/app:1.2.3\n") mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 8999\n") // flowed by the sync - mustWrite(t, stacks.AppliedMetaFile(stackDir), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 80\n") // the pinned version's + mustWrite(t, stacks.AppliedMetaFile(stackDir), "display_name: Example\nhealthcheck:\n checks:\n - type: api\n port: 80\n") // the pinned version's mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\nenv: {}\n") m := &Manager{ logger: log.New(io.Discard, "", 0), diff --git a/controller/internal/backup/restore_dir_err.go b/controller/internal/backup/restore_dir_err.go new file mode 100644 index 0000000..22bc860 --- /dev/null +++ b/controller/internal/backup/restore_dir_err.go @@ -0,0 +1,78 @@ +package backup + +import ( + "errors" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" +) + +// restoreDirError (R-362) turns a failure to create a restore's directory into the truth when the +// cause is a drive that went away. Measured 2026-08-21: the guest-visible bind was unmounted 4 s into +// a scratch restore and the household read „restore dir: mkdir /mnt/felhom-drives/hdd_1/backups: +// permission denied" — a correct refusal that sent the reader after a permissions problem that did +// not exist. The settings flag can lag a detach by seconds, so the drive is also checked directly: +// a drive under the managed parent that is no longer a mount point is gone, whatever the flag says. +// Any other failure keeps its original wording. Pinned by TestR362_DetachedDriveIsNamed. +func (m *Manager) restoreDirError(dir string, err error) error { + if err == nil { + return nil + } + if !errors.Is(err, fs.ErrPermission) && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("restore dir: %w", err) + } + sp, ok := m.registeredDriveHolding(dir) + if !ok || !m.driveGone(sp) { + return fmt.Errorf("restore dir: %w", err) + } + name := sp.Label + if strings.TrimSpace(name) == "" { + name = sp.Path + } + m.logger.Printf("[WARN] [backup] restore dir %s: %v — the drive %s (%s) is not connected; reported as a missing drive (R-362)", dir, err, sp.Path, name) + return util.MsgError("err.backup.restore_drive_gone", name) +} + +// registeredDriveHolding returns the registered storage path that contains dir (longest match). +func (m *Manager) registeredDriveHolding(dir string) (settings.StoragePath, bool) { + if m == nil || m.settings == nil { + return settings.StoragePath{}, false + } + clean := filepath.Clean(dir) + var best settings.StoragePath + found := false + for _, sp := range m.settings.GetStoragePaths() { + root := filepath.Clean(sp.Path) + if root == "" || root == "/" { + continue + } + if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) { + if !found || len(root) > len(filepath.Clean(best.Path)) { + best, found = sp, true + } + } + } + return best, found +} + +// driveGone: the registry already says disconnected, or the drive lives under the managed parent +// (where every enrolled drive is a bind mount) and is no longer a mount point. +func (m *Manager) driveGone(sp settings.StoragePath) bool { + if sp.Disconnected { + return true + } + parent := filepath.Clean(settings.NetworkMountRoot) + string(filepath.Separator) + if !strings.HasPrefix(filepath.Clean(sp.Path)+string(filepath.Separator), parent) { + return false // a legacy path outside the managed parent: only the flag can say it is gone + } + mounted := system.IsMountPoint + if m.driveMountedFn != nil { + mounted = m.driveMountedFn + } + return !mounted(sp.Path) +} diff --git a/controller/internal/backup/restore_record.go b/controller/internal/backup/restore_record.go index 57b75e7..a116613 100644 --- a/controller/internal/backup/restore_record.go +++ b/controller/internal/backup/restore_record.go @@ -133,3 +133,22 @@ func (m *Manager) persistRestoreRecordLocked() { m.logger.Printf("[WARN] [backup] could not persist the restore record to %s: %v (kept in memory)", m.opRecordPath, err) } } + +// ClearInterruptedRestore drops an app's standing interrupted-restore notice and persists the record +// (R-552). Called when the app is REMOVED: the notice asks the household to run the restore again, +// and for an app that no longer exists that is a card about nothing, shown for ever — before this, +// only a new restore of the same app (BeginRestoreOp) ever cleared it. Reports whether a notice was +// there. Pinned by TestClearInterruptedRestore_DropsTheNoticeAndPersists. +func (m *Manager) ClearInterruptedRestore(stack string) bool { + if m == nil || stack == "" { + return false + } + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.opInterrupted[stack]; !ok { + return false + } + delete(m.opInterrupted, stack) + m.persistRestoreRecordLocked() + return true +} diff --git a/controller/internal/backup/shares_restore.go b/controller/internal/backup/shares_restore.go index 8e9e298..c8e02a5 100644 --- a/controller/internal/backup/shares_restore.go +++ b/controller/internal/backup/shares_restore.go @@ -113,7 +113,7 @@ func (m *Manager) RestoreSharesScratch(ctx context.Context) error { return util.MsgError("err.backup.nincs_visszaallithato_megosztas_mentes", err) } if err := os.MkdirAll(scratch, 0o755); err != nil { - return fmt.Errorf("restore dir: %w", err) + return m.restoreDirError(scratch, err) } t := m.settings.GetOffboxTarget() base, env := m.offboxBaseArgs(t) diff --git a/controller/internal/family/family.go b/controller/internal/family/family.go index e5416e7..31e482b 100644 --- a/controller/internal/family/family.go +++ b/controller/internal/family/family.go @@ -104,7 +104,7 @@ func Open(dir string) (*Store, error) { // SetClock and SetCost are test seams. func (s *Store) SetClock(f func() time.Time) { s.now = f } -func (s *Store) SetCost(c int) { s.cost = c } +func (s *Store) SetCost(c int) { s.cost = c } func (s *Store) saveLocked() error { f := file{Members: []Member{}, Sessions: []Session{}} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 6ee7bd8..69cb949 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -385,6 +385,7 @@ "backups_remote.a_tavoli_tarhelyen_levo_mentesek": "The backups on the remote storage were made with an earlier key that is no longer available (usually after a reinstall). Because of that, no new backup can be written to the store right now. The existing backups are not damaged. This machine cannot tell whether they can be opened later — that depends on whether their key still exists. If you need them, write to us.", "backups_remote.a_tavoli_tarolo_masik_kulccsal": "The remote store holds backups made with a different key", "backups_remote.a_teljes_mentes_tullepne_a": "The full backup would go over the storage quota — only the configuration and the database are backed up.", + "backups_remote.abandon_overdue": "As you asked, your earlier remote backups have been due for deletion since {{.AbandonDate}}: we will permanently delete them during the next daily maintenance.", "backups_remote.adatbazisok_mentese_a_pillanatkephez": "Backing up databases for the snapshot…", "backups_remote.adatok_visszaszerzese": "Retrieve data", "backups_remote.addig_meggondolhatod_magad_ha_megvan": "Until then you can change your mind: if you have your recovery code,\n your backups can be retrieved, and the deletion is cancelled.", @@ -394,6 +395,7 @@ "backups_remote.biztonsagi_mentes_tavoli_mentes": "Backup — Remote backup", "backups_remote.cel_cime_ip_vagy_hosztnev": "Target address (IP or hostname — NAS or SFTP server)", "backups_remote.ehhez_a_gephez_egy_korabbi": "This machine has an earlier recovery code, and your earlier backups still exist.\n Creating a new code would make your old backups unreachable, so it cannot start now.\n First enter your existing code — or tell us there if you do not want the earlier data back.", + "backups_remote.elapsed_seconds_unit": "s", "backups_remote.fajl": "files", "backups_remote.feldolgozas_alatt": "· now processing:", "backups_remote.felhasznalo": "User", @@ -1164,6 +1166,7 @@ "disk.err.target_required": "a target storage is required for the move", "disk.err.unknown_mode": "unknown mode (migrate or anyway)", "disk.err.wipe_failed": "the erase failed: %s", + "err.backup.restore_drive_gone": "The drive “%s” cannot be reached, so the restore could not start. Connect the drive again, then start the restore again.", "err.stacks.remove_keep_data_while_support": "While support is working on this app, its data cannot be deleted — you can remove the app and keep its data.", "err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "the backup holds no app data (0 data folders, 0 volumes for an app that stores data)", "err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "the contents of data folder %q are missing from the package", @@ -1412,10 +1415,10 @@ "flash.offbox.first_setup_needs_key": "The first setup needs both the SSH private key and the target machine’s known-host line.", "flash.offbox.full_needs_confirmation": "The full restore cannot run without your confirmation.", "flash.offbox.full_restore_started": "The full restore has started — the status updates here.", - "flash.offbox.mgr_unavailable": "The backup manager is not reachable.", - "flash.offbox.mgr_unreachable": "The backup manager cannot be reached.", + "flash.offbox.mgr_unavailable": "Backups cannot be managed right now. Try again in a few minutes; if it still does not work, contact Felhom support.", + "flash.offbox.mgr_unreachable": "Backups cannot be managed right now. Try again in a few minutes; if it still does not work, contact Felhom support.", "flash.offbox.needs_confirmation": "The restore needs your confirmation.", - "flash.offbox.not_orphaned": "The off-site store is not orphaned.", + "flash.offbox.not_orphaned": "There is no need to start a new remote backup: your current remote backup opens normally and keeps working. If you still see a problem with it, contact Felhom support.", "flash.offbox.path_absolute": "The storage path must be absolute (it has to start with /).", "flash.offbox.recover_started": "The recovery has started — the status updates here.", "flash.offbox.repo_orphaned": "The remote store is orphaned — start a new remote backup first, the way the card shows.", @@ -1552,6 +1555,7 @@ "layout.a_korabbi_tavoli_menteseid_megvannak": "Your earlier remote backups are safe — you need your recovery code to open them.", "layout.a_korabbi_tavoli_menteseid_megvannak_2": "Your earlier remote backups are safe, but this device needs your recovery code for them.", "layout.a_korabbi_tavoli_menteseidet_nap": "We will permanently delete your earlier remote backups in {{.RecoveryAbandonDays}} {{if eq .RecoveryAbandonDays 1}}day{{else}}days{{end}} ({{.RecoveryAbandonDate}}), as you asked.", + "layout.abandon_overdue": "As you asked, your earlier remote backups have been due for deletion since {{.RecoveryAbandonDate}}: we will permanently delete them during the next daily maintenance.", "layout.a_tavoli_mentes_szunetel_amig": "Remote backup is paused until you create your recovery code.\n Create recovery code →", "layout.a_vezerlopult_meg_nincs_jelszoval": "The dashboard is not protected by a password yet — we will send you the setup code by e-mail soon.", "layout.addig_meg_visszaszerezheted_oket_a": "Until then you can still get them back with your recovery code.", @@ -1689,6 +1693,7 @@ "monitoring.utolso_sikeres_jelentes": "Last successful report", "monitoring.uzemido": "Uptime", "note.offsite.fail_head": "The remote backup failed", + "note.offsite.fail_locked": "The remote store is still locked by an earlier run that was cut short, and the backup could not unlock it", "note.offsite.fail_no_repo": "There is no backup store at the remote target", "note.offsite.fail_no_units": "There was nothing to back up: none of the selected apps has a backup", "note.offsite.fail_orphaned": "The remote store was made with an earlier key that is no longer available", @@ -1829,6 +1834,11 @@ "recovery.ugyhogy_tartsd_keznel": "so keep it at hand.", "recovery.vissza_a_kezdolapra": "Back to the home page", "recovery.visszaszerzese": "your data", + "restore.refuse.files.head": "This backup does not hold the files of the app, so we do not restore the database over them — the files stay where they are. ", + "restore.refuse.files.none": "There is no copy of these files at the moment — turn on the remote backup, or connect a second drive.", + "restore.refuse.files.offsite": "The files can be restored from the remote copy: Backup → Restore, “Full restore (files + database)”.", + "restore.refuse.files.second_drive": "The files can be restored from the copy on the second drive: “Restore files”.", + "restore.refuse.files.second_drive_whole": "The app can be brought back with its files from the copy on the second drive: “Full restore from the copy”.", "settings.app_email_off": "App email is off.", "settings.app_email_on": "App email is on. Turn it on for each app that should send email.", "settings.app_email_save_error": "Something went wrong while saving the app email setting", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 7fd594d..9bace62 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -381,6 +381,7 @@ "backups_remote.a_tavoli_tarhelyen_levo_mentesek": "A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek. Azt viszont ez a gép nem tudja megállapítani, hogy később megnyithatók-e — ez attól függ, megvan-e még a hozzájuk tartozó kulcs. Ha szükséged van rájuk, írj nekünk.", "backups_remote.a_tavoli_tarolo_masik_kulccsal": "A távoli tároló másik kulccsal készült mentéseket tartalmaz", "backups_remote.a_teljes_mentes_tullepne_a": "A teljes mentés túllépné a tárhelykeretet — csak a konfiguráció és az adatbázis kerül mentésre.", + "backups_remote.abandon_overdue": "A kérésed szerint a korábbi távoli mentéseid törlése {{.AbandonDate}} óta esedékes: a következő napi karbantartáskor véglegesen töröljük őket.", "backups_remote.adatbazisok_mentese_a_pillanatkephez": "Adatbázisok mentése a pillanatképhez…", "backups_remote.adatok_visszaszerzese": "Adatok visszaszerzése", "backups_remote.addig_meggondolhatod_magad_ha_megvan": "Addig meggondolhatod magad: ha megvan a helyreállítási kódod,\n a mentéseid visszaszerezhetők, és a törlés elmarad.", @@ -390,6 +391,7 @@ "backups_remote.biztonsagi_mentes_tavoli_mentes": "Biztonsági mentés — Távoli mentés", "backups_remote.cel_cime_ip_vagy_hosztnev": "Cél címe (IP vagy hosztnév — NAS vagy SFTP-kiszolgáló)", "backups_remote.ehhez_a_gephez_egy_korabbi": "Ehhez a géphez egy korábbi helyreállítási kód tartozik, és a korábbi mentéseid még megvannak.\n Új kód létrehozása a régi mentéseidet elérhetetlenné tenné, ezért most nem indítható.\n Előbb add meg a meglévő kódodat — vagy ott jelezheted, ha nem kéred vissza a korábbi adatokat.", + "backups_remote.elapsed_seconds_unit": "mp", "backups_remote.fajl": "fájl", "backups_remote.feldolgozas_alatt": "· feldolgozás alatt:", "backups_remote.felhasznalo": "Felhasználó", @@ -1159,6 +1161,7 @@ "disk.err.target_required": "céltároló kötelező az áthelyezéshez", "disk.err.unknown_mode": "ismeretlen mód (migrate vagy anyway)", "disk.err.wipe_failed": "törlés sikertelen: %s", + "err.backup.restore_drive_gone": "A(z) „%s” meghajtó nem érhető el, ezért a visszaállítás nem tudott elindulni. Csatlakoztasd újra a meghajtót, majd indítsd el újra a visszaállítást.", "err.stacks.remove_keep_data_while_support": "Amíg az ügyfélszolgálat foglalkozik az alkalmazással, az adatai nem törölhetők — az alkalmazást az adatai megtartásával eltávolíthatod.", "err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet egy adattárolós alkalmazásnál)", "err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "a(z) %q adatkönyvtár tartalma hiányzik a csomagból", @@ -1403,10 +1406,10 @@ "flash.offbox.first_setup_needs_key": "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.", "flash.offbox.full_needs_confirmation": "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", "flash.offbox.full_restore_started": "A teljes visszaállítás elindult — az állapot itt frissül.", - "flash.offbox.mgr_unavailable": "A mentéskezelő nem elérhető.", - "flash.offbox.mgr_unreachable": "A mentéskezelő nem érhető el.", + "flash.offbox.mgr_unavailable": "A mentések kezelése most nem érhető el. Próbáld újra néhány perc múlva; ha akkor sem megy, keresd a Felhom ügyfélszolgálatát.", + "flash.offbox.mgr_unreachable": "A mentések kezelése most nem érhető el. Próbáld újra néhány perc múlva; ha akkor sem megy, keresd a Felhom ügyfélszolgálatát.", "flash.offbox.needs_confirmation": "A visszaállításhoz megerősítés szükséges.", - "flash.offbox.not_orphaned": "Az offsite tároló nincs elárvult állapotban.", + "flash.offbox.not_orphaned": "Új távoli mentést nem kell indítani: a mostani távoli mentésed rendben megnyitható, és tovább működik. Ha mégis gondot látsz vele, keresd a Felhom ügyfélszolgálatát.", "flash.offbox.path_absolute": "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", "flash.offbox.recover_started": "A helyreállítás elindult — az állapot itt frissül.", "flash.offbox.repo_orphaned": "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", @@ -1540,6 +1543,7 @@ "layout.a_korabbi_tavoli_menteseid_megvannak": "A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges.", "layout.a_korabbi_tavoli_menteseid_megvannak_2": "A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges.", "layout.a_korabbi_tavoli_menteseidet_nap": "A korábbi távoli mentéseidet {{.RecoveryAbandonDays}} nap múlva ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint.", + "layout.abandon_overdue": "A korábbi távoli mentéseid törlése a kérésed szerint {{.RecoveryAbandonDate}} óta esedékes: a következő napi karbantartáskor véglegesen töröljük őket.", "layout.a_tavoli_mentes_szunetel_amig": "A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot.\n Helyreállítási kód létrehozása →", "layout.a_vezerlopult_meg_nincs_jelszoval": "A vezérlőpult még nincs jelszóval védve — a beállító kódot hamarosan e-mailben küldjük.", "layout.addig_meg_visszaszerezheted_oket_a": "Addig még visszaszerezheted őket a helyreállítási kóddal.", @@ -1677,6 +1681,7 @@ "monitoring.utolso_sikeres_jelentes": "Utolsó sikeres jelentés", "monitoring.uzemido": "Üzemidő", "note.offsite.fail_head": "A távoli mentés nem sikerült", + "note.offsite.fail_locked": "A távoli tárhelyet egy korábbi, félbeszakadt futás zárva hagyta, és a mentés nem tudta feloldani", "note.offsite.fail_no_repo": "A távoli tárhelyen nincs mentési adattár", "note.offsite.fail_no_units": "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése", "note.offsite.fail_orphaned": "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült", @@ -1817,6 +1822,11 @@ "recovery.ugyhogy_tartsd_keznel": "úgyhogy tartsd kéznél.", "recovery.vissza_a_kezdolapra": "Vissza a kezdőlapra", "recovery.visszaszerzese": "visszaszerzése", + "restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ", + "restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót.", + "restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.", + "restore.refuse.files.second_drive": "A fájlok a második meghajtó másolatából állíthatók vissza: „Fájlok visszaállítása”.", + "restore.refuse.files.second_drive_whole": "Az alkalmazás a fájljaival együtt a második meghajtó másolatából állítható vissza: „Teljes visszaállítás a másolatból”.", "settings.app_email_off": "Alkalmazás-email kikapcsolva.", "settings.app_email_on": "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél.", "settings.app_email_save_error": "Hiba az alkalmazás-email beállítás mentésekor", diff --git a/controller/internal/i18n/r603_escape_test.go b/controller/internal/i18n/r603_escape_test.go new file mode 100644 index 0000000..58d7467 --- /dev/null +++ b/controller/internal/i18n/r603_escape_test.go @@ -0,0 +1,143 @@ +package i18n + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" +) + +// R-603: html/template escapes ' " & < > in DATA. A bundle value that Go code renders into a page as +// data (a flash, a note, an error) therefore never appears verbatim on the page: "The system backup's +// drive…" arrives as "backup's", and a strings.Contains assertion for it fails exactly like a +// missing sentence — which sends the next person to re-fix a handler that was never broken. (Template +// copy is expanded textually by Expand and is NOT escaped; only Go-named values travel as data.) +// +// THE GATE: a Go-named value may not carry an HTML-escapable character unless it is registered below. +// The registered set is the measured state on 2026-10-05; a NEW value fails here with this pointer, +// so whoever writes it either rewords it or registers it AND asserts it with html.EscapeString(want). +var r603Registered = map[string]bool{ + "alert.endpoint_drift": true, "kept.choice.title": true, "kept.choice.fresh.desc": true, + "err.backup.unit_version_mismatch": true, "note.tier2.unit_preserved": true, + "err.stacks.setup_gate_failed": true, "err.kept.occupied": true, "update.refusal.no_backup": true, + "update.error.journal_failed": true, "note.unit_restore_settings_only": true, + "note.tier2_no_coverage": true, "note.tier2_unit_available": true, "note.tier2_unit_not_covered": true, + "note.tier2_unit_confirm_base": true, "note.tier2_unit_stale_clause": true, + "note.tier2_unit_stale_notice_fmt": true, "note.restore.whole_files": true, + "note.restore.scratch_state": true, +} + +var r603Escapable = regexp.MustCompile(`['"&<>]`) +var r603KeyShape = regexp.MustCompile(`^[a-z][a-z0-9_]*(?:\.[a-z0-9_\-]+)+$`) + +// goNamedKeys returns every bundle key that appears as a string literal in non-test Go source under +// the given roots — the same "named in Go" rule scripts/i18n_go_parity.py applies. +func goNamedKeys(t *testing.T, bundle map[string]string, roots ...string) map[string]string { + t.Helper() + out := map[string]string{} + fset := token.NewFileSet() + for _, root := range roots { + err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return err + } + f, perr := parser.ParseFile(fset, p, nil, 0) + if perr != nil { + return perr + } + ast.Inspect(f, func(n ast.Node) bool { + if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING { + if v, uerr := strconv.Unquote(lit.Value); uerr == nil && r603KeyShape.MatchString(v) { + if _, ok := bundle[v]; ok { + out[v] = p + } + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + } + return out +} + +func r603Load(t *testing.T, lang string) map[string]string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("locales", lang+".json")) + if err != nil { + t.Fatal(err) + } + var generic map[string]interface{} + if err := json.Unmarshal(raw, &generic); err != nil { + t.Fatal(err) + } + out := map[string]string{} + for k, v := range generic { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +// r603Offenders is the gate's verdict for one bundle: Go-named values carrying an escapable character +// that are not registered. +func r603Offenders(bundle, named map[string]string) []string { + var bad []string + for k := range named { + if r603Escapable.MatchString(bundle[k]) && !r603Registered[k] { + bad = append(bad, k) + } + } + sort.Strings(bad) + return bad +} + +func TestR603_GoNamedValuesDoNotHideBehindHTMLEscaping(t *testing.T) { + hu, en := r603Load(t, "hu"), r603Load(t, "en") + named := goNamedKeys(t, hu, filepath.Join("..", "..", "internal"), filepath.Join("..", "..", "cmd")) + if len(named) < 100 { + t.Fatalf("control: only %d Go-named keys found — the source walk is not reaching the code", len(named)) + } + // Decoy: a planted apostrophe in a Go-named value that is not registered must be caught. + var some string + for k := range named { + if !r603Registered[k] { + some = k + break + } + } + planted := map[string]string{} + for k, v := range en { + planted[k] = v + } + planted[some] = "The drive's copy" + if got := r603Offenders(planted, named); len(got) != 1 || got[0] != some { + t.Fatalf("control: a planted apostrophe in %s was not caught (got %v)", some, got) + } + + for lang, b := range map[string]map[string]string{"hu": hu, "en": en} { + for _, k := range r603Offenders(b, named) { + t.Errorf("R-603 [%s] %s = %q (named in %s) carries a character html/template escapes in data "+ + "(' \" & < >): on the page it is not these bytes, and a strings.Contains assertion for it fails "+ + "like a missing sentence. Reword it (a typographic ’ is not escaped), or register it in "+ + "r603Registered and assert it with html.EscapeString(want).", lang, k, b[k], named[k]) + } + } + // A registration that no longer needs to be there is reported, so the list only shrinks. + for k := range r603Registered { + if _, ok := named[k]; !ok || !r603Escapable.MatchString(en[k]+hu[k]) { + t.Errorf("R-603: %s is registered but no longer a Go-named value with an escapable character — remove it", k) + } + } +} diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 9b97699..107e167 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -83,8 +83,8 @@ const ( TunnelNetwork = "felhom-tunnel" TunnelSubnet = "172.16.253.0/29" TunnelGateway = "172.16.253.1" - TunnelAddr = "172.16.253.2" // cloudflared — the ONLY address traefik believes forwarded headers from - TunnelTraefikAddr = "172.16.253.3" // traefik's own place on the tunnel network + TunnelAddr = "172.16.253.2" // cloudflared — the ONLY address traefik believes forwarded headers from + TunnelTraefikAddr = "172.16.253.3" // traefik's own place on the tunnel network TunnelIPRange = "172.16.253.4/30" // where docker may put anything else: never .2 or .3 // ForwardedMiddleware is the entrypoint middleware every websecure request passes (RenderForwardedHeaders). ForwardedMiddleware = "felhom-forwarded" diff --git a/controller/internal/notify/r636_oom_storm_test.go b/controller/internal/notify/r636_oom_storm_test.go index bd83277..5239041 100644 --- a/controller/internal/notify/r636_oom_storm_test.go +++ b/controller/internal/notify/r636_oom_storm_test.go @@ -29,7 +29,7 @@ func TestR636_TwentyKillsInThirtyMinutesIsOneStorm(t *testing.T) { }{{19, 0}, {20, 1}, {200, 1}} { n, clock, got := stormRecorder(t) start := clock.Add(-time.Hour).Format(time.RFC3339Nano) // started long before: no zero baseline - for i := int64(0); i <= 40; i++ { // 20 minutes of 30 s scans, counter 5 → 5+kills + for i := int64(0); i <= 40; i++ { // 20 minutes of 30 s scans, counter 5 → 5+kills n.NotifyAppOOM("romm", "romm", start, 5+c.kills*i/40, "1280M", "1279M") *clock = clock.Add(30 * time.Second) } diff --git a/controller/internal/quiesce/tiers_test.go b/controller/internal/quiesce/tiers_test.go index 5bad53e..602e4ad 100644 --- a/controller/internal/quiesce/tiers_test.go +++ b/controller/internal/quiesce/tiers_test.go @@ -55,6 +55,7 @@ func (b *tierBackend) Tiers(context.Context) ([]BackupTier, error) { } return b.tiers, nil } + // DueFor returns a nil age with an EMPTY age_state — i.e. the pre-v0.105.0 (legacy) shape, which // keeps every suite written before R-88 Part 2 asserting exactly the behaviour it always did. func (b *tierBackend) DueFor(_ context.Context, target string) (bool, *int64, string, error) { diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 197981c..e060ab8 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -43,12 +43,12 @@ type DeleteResponse struct { // DeleteResponse, plus the backup half: BackupPathsRefused carries every backup path the removal // declined to touch and why — until v0.236.0 that refusal existed only as a WARN log line. type RemoveResponse struct { - Removed string `json:"removed"` - VolumesRemoved []string `json:"volumes_removed"` - HDDPathsRemoved []string `json:"hdd_paths_removed"` - HDDPathsPreserved []string `json:"hdd_paths_preserved"` - HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"` - HDDNote string `json:"hdd_note,omitempty"` + Removed string `json:"removed"` + VolumesRemoved []string `json:"volumes_removed"` + HDDPathsRemoved []string `json:"hdd_paths_removed"` + HDDPathsPreserved []string `json:"hdd_paths_preserved"` + HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"` + HDDNote string `json:"hdd_note,omitempty"` // UserdataKept: as DeleteResponse.UserdataKept (decision 67, R-800). UserdataKept []string `json:"userdata_kept"` BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"` diff --git a/controller/internal/stacks/life_records.go b/controller/internal/stacks/life_records.go index f60b17a..b15afd8 100644 --- a/controller/internal/stacks/life_records.go +++ b/controller/internal/stacks/life_records.go @@ -36,7 +36,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) { // opened; a gate that was still closed stays closed (its probe opens it if the restored data is set up). // No prior record (a removed app, kept data, a rebuilt guest) = no gate: the data comes back with its admin. cfg.SetupGate = prior.SetupGate - cfg.FamilyGate = prior.FamilyGate // v0.287.0: a restore never un-gates a family app + cfg.FamilyGate = prior.FamilyGate // v0.287.0: a restore never un-gates a family app cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced cfg.DefaultLogin = prior.DefaultLogin cfg.AfterSetup = prior.AfterSetup diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 8380587..6c45716 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -1210,6 +1210,60 @@ func deepCopyStack(s *Stack) Stack { cp.Meta.InitialCreds = &icCopy } + // R-591 follow-up: every other reference in Meta. Pinned by TestDeepCopyStackMetaSharesNoReference, + // which fills every pointer/slice/map reachable from Meta and fails on any the copy still shares — + // so a field added to Metadata later fails there until it is copied here. + cp.Meta.AppInfo.UseCases = cloneStrs(s.Meta.AppInfo.UseCases) + cp.Meta.AppInfo.FirstSteps = cloneStrs(s.Meta.AppInfo.FirstSteps) + cp.Meta.AppInfo.Prerequisites = cloneStrs(s.Meta.AppInfo.Prerequisites) + cp.Meta.FamilyGateExcept = cloneStrs(s.Meta.FamilyGateExcept) + if s.Meta.DataPaths != nil { + cp.Meta.DataPaths = make([]DataPath, len(s.Meta.DataPaths)) + copy(cp.Meta.DataPaths, s.Meta.DataPaths) + } + if s.Meta.AfterLoad != nil { + al := *s.Meta.AfterLoad + al.Command = cloneStrs(al.Command) + cp.Meta.AfterLoad = &al + } + if s.Meta.AfterInstall != nil { + ai := *s.Meta.AfterInstall + ai.Env, ai.Command = cloneStrs(ai.Env), cloneStrs(ai.Command) + cp.Meta.AfterInstall = &ai + } + if s.Meta.AfterSetup != nil { + as := *s.Meta.AfterSetup + as.Env = cloneStrMap(as.Env) + as.Args, as.Command = cloneStrs(as.Args), cloneStrs(as.Command) + cp.Meta.AfterSetup = &as + } + if s.Meta.SetupDoneProbe != nil { + sp := *s.Meta.SetupDoneProbe + cp.Meta.SetupDoneProbe = &sp + } + if s.Meta.SMTPMapping != nil { + sm := *s.Meta.SMTPMapping + sm.Extra = cloneStrMap(sm.Extra) + cp.Meta.SMTPMapping = &sm + } + if s.Meta.Backup != nil { + b := *s.Meta.Backup + b.Userdata = append([]appbackup.BindSpec(nil), b.Userdata...) + b.HDD = append([]appbackup.BindSpec(nil), b.HDD...) + b.Import = append([]appbackup.BindSpec(nil), b.Import...) + cp.Meta.Backup = &b + } + + // Deep-copy Meta.I18n (R-591): the struct assignment above leaves the map — and every pointer + // and slice inside each overlay — shared with the original. Pinned by + // TestDeepCopyStackI18nIsNotShared. + if s.Meta.I18n != nil { + cp.Meta.I18n = make(map[string]MetadataOverlay, len(s.Meta.I18n)) + for lang, ov := range s.Meta.I18n { + cp.Meta.I18n[lang] = ov.Clone() + } + } + return cp } @@ -1755,3 +1809,15 @@ func AggregateStateForTest(containers []ContainerInfo) ContainerState { // SetKeptUnitFinder wires the backup side's removed-app unit lookup (INIT-ONLY; main.go). func (m *Manager) SetKeptUnitFinder(fn func(app, drive string) string) { m.keptUnitFn = fn } + +// cloneStrMap returns a fresh copy of in, keeping nil as nil. +func cloneStrMap(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/controller/internal/stacks/metadata_i18n.go b/controller/internal/stacks/metadata_i18n.go index 6af7367..dfce3ac 100644 --- a/controller/internal/stacks/metadata_i18n.go +++ b/controller/internal/stacks/metadata_i18n.go @@ -320,3 +320,93 @@ func LocalizeStackPtr(in *Stack, lang string) *Stack { out.Meta = out.Meta.For(lang) return &out } + +// cloneStrPtr returns a fresh pointer to a copy of *p, or nil. +func cloneStrPtr(p *string) *string { + if p == nil { + return nil + } + v := *p + return &v +} + +// cloneStrs returns a fresh copy of in, keeping nil as nil and empty as empty. +func cloneStrs(in []string) []string { + if in == nil { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out +} + +// Clone returns a deep copy of the overlay: no pointer, slice or nested struct is shared with the +// receiver. It exists for deepCopyStack (R-591) — a Stack copy is "a snapshot the caller may +// mutate", and the overlay must be one too. Pinned by TestDeepCopyStackI18nIsNotShared. +func (o MetadataOverlay) Clone() MetadataOverlay { + c := MetadataOverlay{ + Description: cloneStrPtr(o.Description), + } + if o.AppInfo != nil { + ai := AppInfoOverlay{ + Tagline: cloneStrPtr(o.AppInfo.Tagline), + UseCases: cloneStrs(o.AppInfo.UseCases), + FirstSteps: cloneStrs(o.AppInfo.FirstSteps), + Prerequisites: cloneStrs(o.AppInfo.Prerequisites), + DefaultCreds: cloneStrPtr(o.AppInfo.DefaultCreds), + AddPeople: cloneStrPtr(o.AppInfo.AddPeople), + } + c.AppInfo = &ai + } + if o.DeployFields != nil { + c.DeployFields = make([]DeployFieldOverlay, len(o.DeployFields)) + for i, f := range o.DeployFields { + nf := DeployFieldOverlay{ + EnvVar: f.EnvVar, + Label: cloneStrPtr(f.Label), + Description: cloneStrPtr(f.Description), + Placeholder: cloneStrPtr(f.Placeholder), + } + if f.Options != nil { + nf.Options = make([]SelectOptionOverlay, len(f.Options)) + for j, op := range f.Options { + nf.Options[j] = SelectOptionOverlay{Value: op.Value, Label: cloneStrPtr(op.Label)} + } + } + c.DeployFields[i] = nf + } + } + if o.OptionalConfig != nil { + c.OptionalConfig = make([]OptionalConfigGroupOverlay, len(o.OptionalConfig)) + for i, g := range o.OptionalConfig { + ng := OptionalConfigGroupOverlay{ + MatchGroup: g.MatchGroup, + Group: cloneStrPtr(g.Group), + Description: cloneStrPtr(g.Description), + } + if g.Fields != nil { + ng.Fields = make([]OptionalConfigFieldOverlay, len(g.Fields)) + for j, f := range g.Fields { + ng.Fields[j] = OptionalConfigFieldOverlay{EnvVar: f.EnvVar, Label: cloneStrPtr(f.Label), HelpText: cloneStrPtr(f.HelpText)} + } + } + c.OptionalConfig[i] = ng + } + } + if o.Integrations != nil { + c.Integrations = make([]IntegrationOverlay, len(o.Integrations)) + for i, in := range o.Integrations { + c.Integrations[i] = IntegrationOverlay{Target: in.Target, Label: cloneStrPtr(in.Label), Description: cloneStrPtr(in.Description)} + } + } + if o.DataPaths != nil { + c.DataPaths = make([]DataPathOverlay, len(o.DataPaths)) + for i, d := range o.DataPaths { + c.DataPaths[i] = DataPathOverlay{Path: d.Path, Label: cloneStrPtr(d.Label)} + } + } + if o.InitialCreds != nil { + c.InitialCreds = &InitialCredentialsOverlay{Note: cloneStrPtr(o.InitialCreds.Note)} + } + return c +} diff --git a/controller/internal/stacks/r591_copy_i18n_test.go b/controller/internal/stacks/r591_copy_i18n_test.go new file mode 100644 index 0000000..218df4f --- /dev/null +++ b/controller/internal/stacks/r591_copy_i18n_test.go @@ -0,0 +1,61 @@ +package stacks + +import "testing" + +// R-591: deepCopyStack is "a snapshot the caller may mutate". Before the fix the Meta.I18n map +// (and every pointer inside each overlay) was shared, so writing through the copy changed the +// original. The test asserts the CONSEQUENCE: after mutating the copy at every depth, the +// original still reads its own values. +func TestDeepCopyStackI18nIsNotShared(t *testing.T) { + s := func(v string) *string { return &v } + orig := &Stack{Name: "demo"} + orig.Meta.I18n = map[string]MetadataOverlay{ + "en": { + Description: s("orig desc"), + AppInfo: &AppInfoOverlay{Tagline: s("orig tag"), UseCases: []string{"orig use"}}, + DeployFields: []DeployFieldOverlay{{EnvVar: "A", Label: s("orig label"), + Options: []SelectOptionOverlay{{Value: "x", Label: s("orig opt")}}}}, + OptionalConfig: []OptionalConfigGroupOverlay{{MatchGroup: "g", Group: s("orig group"), + Fields: []OptionalConfigFieldOverlay{{EnvVar: "B", HelpText: s("orig help")}}}}, + Integrations: []IntegrationOverlay{{Target: "t", Label: s("orig int")}}, + DataPaths: []DataPathOverlay{{Path: "/p", Label: s("orig path")}}, + InitialCreds: &InitialCredentialsOverlay{Note: s("orig note")}, + }, + } + + cp := deepCopyStack(orig) + en := cp.Meta.I18n["en"] + *en.Description = "MUT" + *en.AppInfo.Tagline = "MUT" + en.AppInfo.UseCases[0] = "MUT" + *en.DeployFields[0].Label = "MUT" + *en.DeployFields[0].Options[0].Label = "MUT" + *en.OptionalConfig[0].Group = "MUT" + *en.OptionalConfig[0].Fields[0].HelpText = "MUT" + *en.Integrations[0].Label = "MUT" + *en.DataPaths[0].Label = "MUT" + *en.InitialCreds.Note = "MUT" + cp.Meta.I18n["de"] = MetadataOverlay{Description: s("MUT")} + + o := orig.Meta.I18n["en"] + checks := map[string]string{ + "description": *o.Description, + "tagline": *o.AppInfo.Tagline, + "use case": o.AppInfo.UseCases[0], + "deploy label": *o.DeployFields[0].Label, + "option label": *o.DeployFields[0].Options[0].Label, + "optional group": *o.OptionalConfig[0].Group, + "optional help": *o.OptionalConfig[0].Fields[0].HelpText, + "integration": *o.Integrations[0].Label, + "data path": *o.DataPaths[0].Label, + "initial creds": *o.InitialCreds.Note, + } + for what, got := range checks { + if got == "MUT" { + t.Errorf("R-591: mutating the copy's %s overlay changed the ORIGINAL — the overlay is shared, not copied", what) + } + } + if _, ok := orig.Meta.I18n["de"]; ok { + t.Errorf("R-591: adding a language to the copy's I18n map added it to the ORIGINAL — the map is shared") + } +} diff --git a/controller/internal/stacks/r591_copy_meta_alias_test.go b/controller/internal/stacks/r591_copy_meta_alias_test.go new file mode 100644 index 0000000..ff11cad --- /dev/null +++ b/controller/internal/stacks/r591_copy_meta_alias_test.go @@ -0,0 +1,109 @@ +package stacks + +import ( + "reflect" + "testing" +) + +// R-591 follow-up: deepCopyStack's contract is "a snapshot the caller may mutate", and the I18n hole +// was not the only one — DataPaths, AfterLoad (and every other reference added to Metadata after the +// copy was written) stayed shared. Instead of one assertion per field, this fills EVERY pointer, slice +// and map reachable from Stack.Meta with a non-empty value and then walks the copy beside the original: +// any reference the two share is a field a caller's write would leak through. A field added to +// Metadata tomorrow is covered the day it is added. +func TestDeepCopyStackMetaSharesNoReference(t *testing.T) { + orig := &Stack{Name: "demo"} + fillAll(reflect.ValueOf(&orig.Meta).Elem(), 0) + cp := deepCopyStack(orig) + var shared []string + findAliases(reflect.ValueOf(orig.Meta), reflect.ValueOf(cp.Meta), "Meta", &shared) + for _, p := range shared { + t.Errorf("R-591: deepCopyStack leaves %s shared with the original — a write through the copy changes the stack", p) + } +} + +// fillAll gives every pointer, slice (one element) and map (one entry) under v a non-nil value. +func fillAll(v reflect.Value, depth int) { + if depth > 6 || !v.CanSet() && v.Kind() != reflect.Struct { + return + } + switch v.Kind() { + case reflect.Ptr: + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + fillAll(v.Elem(), depth+1) + case reflect.Slice: + if v.Len() == 0 { + v.Set(reflect.MakeSlice(v.Type(), 1, 1)) + } + fillAll(v.Index(0), depth+1) + case reflect.Map: + if v.Len() == 0 { + m := reflect.MakeMap(v.Type()) + k := reflect.New(v.Type().Key()).Elem() + if k.Kind() == reflect.String { + k.SetString("k") + } + e := reflect.New(v.Type().Elem()).Elem() + fillAll(e, depth+1) + m.SetMapIndex(k, e) + v.Set(m) + } + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).IsExported() { + fillAll(v.Field(i), depth+1) + } + } + case reflect.String: + if v.String() == "" { + v.SetString("x") + } + } +} + +// findAliases records every pointer/slice/map under a and b that points at the same memory. +func findAliases(a, b reflect.Value, path string, out *[]string) { + switch a.Kind() { + case reflect.Ptr: + if a.IsNil() || b.IsNil() { + return + } + if a.Pointer() == b.Pointer() { + *out = append(*out, path) + return + } + findAliases(a.Elem(), b.Elem(), path, out) + case reflect.Slice: + if a.Len() == 0 || b.Len() == 0 { + return + } + if a.Pointer() == b.Pointer() { + *out = append(*out, path) + return + } + for i := 0; i < a.Len() && i < b.Len(); i++ { + findAliases(a.Index(i), b.Index(i), path+"[]", out) + } + case reflect.Map: + if a.Len() == 0 || b.Len() == 0 { + return + } + if a.Pointer() == b.Pointer() { + *out = append(*out, path) + return + } + for _, k := range a.MapKeys() { + if bv := b.MapIndex(k); bv.IsValid() { + findAliases(a.MapIndex(k), bv, path+"[k]", out) + } + } + case reflect.Struct: + for i := 0; i < a.NumField(); i++ { + if a.Type().Field(i).IsExported() { + findAliases(a.Field(i), b.Field(i), path+"."+a.Type().Field(i).Name, out) + } + } + } +} diff --git a/controller/internal/web/disk_health.go b/controller/internal/web/disk_health.go index 5601be2..20b73ee 100644 --- a/controller/internal/web/disk_health.go +++ b/controller/internal/web/disk_health.go @@ -2,6 +2,7 @@ package web import ( "context" + "sort" "strconv" "sync" "time" @@ -137,8 +138,16 @@ func (s *Server) diskHealthRows(ctx context.Context) []DiskHealthRow { } s.diskHealth.mu.Unlock() + // R-568: the agent's response order is not stable between calls, so the card used to swap its + // rows between visits. Order by the durable identity (diskKey) — the same key the persisted + // state uses — so "the second disk" is the same disk on every visit. Stable, so two entries for + // one physical disk keep their relative order. Pinned by TestDiskHealthRows_OrderIsStable. + disks := make([]agentapi.DiskInfo, len(resp.Disks)) + copy(disks, resp.Disks) + sort.SliceStable(disks, func(i, j int) bool { return diskKey(disks[i]) < diskKey(disks[j]) }) + var rows []DiskHealthRow - for _, d := range resp.Disks { + for _, d := range disks { if !isPhysicalDisk(d) { continue } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index ad2c439..28c8424 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1165,6 +1165,11 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { data["AbandonActive"] = true data["AbandonDaysLeft"] = st.DaysLeft data["AbandonDate"] = st.DueAt.Format("2006-01-02") + // R-365: DaysLeft is a ceiling, so 0 means the due moment has PASSED (the terminal step waits + // for the next daily sweep, up to ~29 h). The future-tense „… napján véglegesen töröljük" then + // named a date in the past; the overdue sentence says the deletion is due and when it runs. + // Pinned by TestR365_OverdueCountdownIsNotFutureTense. + data["AbandonOverdue"] = st.DaysLeft <= 0 // R-302: this block makes the SAME retrieval promise as the banner, under a different verb // („visszaszerezhetők" vs the banner's „visszaszerezheted"), which is why it was a fourth // instance nobody had counted. Same single derivation — fixing one surface and not the other @@ -1727,25 +1732,43 @@ func (s *Server) tier2DestLabel(destPath, systemDataPath, lang string) string { return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir)) } -// missingFileLegsRefusal builds the Hungarian sentence shown when a unit restore is refused because -// the unit carries no copy of the app's files (R-538). It names the route that CAN return them, and -// when there is none it says so rather than implying one exists. +// missingFileLegsRefusal builds the sentence shown when a unit restore is refused because the unit +// carries no copy of the app's files (R-538). It names the route that CAN return them, and when there +// is none it says so rather than implying one exists. // -// The three branches are the three real states, in the order a customer can act on them: the off-site -// copy (a full restore brings files AND database), the second drive (its file half is its own -// action), and nothing. -func (s *Server) missingFileLegsRefusal(ctx context.Context, stackName string) string { - const head = "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. " +// The branches are the real states, in the order a customer can act on them: the off-site copy (a full +// restore brings files AND database), the second drive's WHOLE copy (decision 26, v0.269.0 — the +// „Teljes visszaállítás a másolatból" button brings files AND database back), the second drive's file +// copy alone (its file half is its own action), and nothing. R-675: the whole-copy branch was missing, +// so a household with a whole copy was sent to the file-only action. Rendered in the reader's language. +func (s *Server) missingFileLegsRefusal(ctx context.Context, lang, stackName string) string { + offsite, whole, files := false, false, false if s.backupMgr != nil { rows, _ := s.offsiteRestoreRows(ctx) - if row := resolveOffsiteRestoreApp(rows, stackName); row != nil { - return head + "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”." - } - if cov, err := s.backupMgr.Tier2RestoreCoverage(stackName); err == nil && cov.CanRestore() { - return head + "A fájlok a második meghajtó másolatából állíthatók vissza: „Fájlok visszaállítása”." + offsite = resolveOffsiteRestoreApp(rows, stackName) != nil + if !offsite { + whole = s.backupMgr.WholeOnTier(stackName, backup.UpdateTierSecondDrive) + if cov, err := s.backupMgr.Tier2RestoreCoverage(stackName); err == nil && cov.CanRestore() { + files = true + } } } - return head + "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót." + return s.msgLang(lang, "restore.refuse.files.head") + s.msgLang(lang, missingFileLegsRouteKey(offsite, whole, files)) +} + +// missingFileLegsRouteKey picks the route sentence for missingFileLegsRefusal (R-675). Pure, so every +// branch is pinned by TestR675_RefusalNamesTheWholeCopy. +func missingFileLegsRouteKey(offsite, wholeOnSecondDrive, filesOnSecondDrive bool) string { + switch { + case offsite: + return "restore.refuse.files.offsite" + case wholeOnSecondDrive: + return "restore.refuse.files.second_drive_whole" + case filesOnSecondDrive: + return "restore.refuse.files.second_drive" + default: + return "restore.refuse.files.none" + } } func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { @@ -1791,7 +1814,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { // it is the database-only restore under existing files that `09` §3 decision 25 ruled out (option B). // The way back for such an app is the second drive's whole restore (decision 26) or the off-site one. if legs := s.backupMgr.DeclaredDriveFileLegs(stackName); len(legs) > 0 { - msg := s.missingFileLegsRefusal(r.Context(), stackName) + msg := s.missingFileLegsRefusal(r.Context(), s.langFor(r), stackName) s.logger.Printf("[WARN] [web] restore refused for %s: unit carries no file leg (%d drive path(s))", stackName, len(legs)) http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound) return diff --git a/controller/internal/web/i18n_cases_b_test.go b/controller/internal/web/i18n_cases_b_test.go index 15e6260..3ff39f8 100644 --- a/controller/internal/web/i18n_cases_b_test.go +++ b/controller/internal/web/i18n_cases_b_test.go @@ -156,6 +156,14 @@ func i18nCasesB() []i18nCase { d["AbandonActive"], d["AbandonDate"], d["AbandonDaysLeft"] = true, "2026-09-30", 13 }) }}, + // R-365: the countdown's due moment has passed and the daily sweep has not run yet. + {"backups_remote_abandon_overdue", "backups_remote", func() map[string]interface{} { + return i18nRemoteData(func(d, o m) { + d["OffboxToggledCount"] = 1 + o["LastStatus"], o["StatsKnown"] = "ok", true + d["AbandonActive"], d["AbandonDate"], d["AbandonDaysLeft"], d["AbandonOverdue"] = true, "2026-09-30", 0, true + }) + }}, {"backups_remote_error", "backups_remote", func() map[string]interface{} { return i18nRemoteData(func(d, o m) { o["LastStatus"], o["Enabled"], o["StatsKnown"], o["RepoSizeHuman"] = "error", false, true, "" diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 70cd75a..ef30ab0 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -100,6 +100,26 @@ func i18nCases() []i18nCase { d["ShareFlash"] = "A megosztás bekapcsolva." return d }}, + // R-365 follow-up: the countdown's due moment has passed, the daily sweep has not run yet. + {"launcher_abandon_overdue", "launcher", func() map[string]interface{} { + d := i18nLayoutData("launcher", "Indítópult") + d["RecoveryBanner"] = true + d["RecoveryBannerBack"] = "/launcher" + d["RecoveryAbandonDays"] = 0 + d["RecoveryAbandonOverdue"] = true + d["RecoveryAbandonDate"] = "2026-09-29" + return d + }}, + {"launcher_abandon_overdue_offered", "launcher", func() map[string]interface{} { + d := i18nLayoutData("launcher", "Indítópult") + d["RecoveryBanner"] = true + d["RecoveryBannerBack"] = "/launcher" + d["RecoveryAbandonDays"] = 0 + d["RecoveryAbandonOverdue"] = true + d["RecoveryAbandonDate"] = "2026-09-29" + d["RecoveryAbandonRetrievalOffered"] = true + return d + }}, {"launcher_empty", "launcher", func() map[string]interface{} { d := i18nLayoutData("launcher", "Indítópult") d["RecoveryBanner"] = true @@ -564,6 +584,38 @@ func huLetter(s string) bool { return strings.ContainsAny(s, "áéíóöőúüűÁÉÍÓÖŐÚÜŰ") } +// asciiHuRe (R-565) is the extractor's ASCII-only Hungarian word list (scripts/i18n_extract.py +// ASCII_HU), plus the words releases B and C found by eye after every test was green (mp, db, FIGYELEM, +// jelenlegi, majd a(z), Konfig, Megtartva, …). An unaccented Hungarian word is invisible to huLetter, +// and those six fragments reached English pages with no test failing. Case-insensitive, whole words. +// Words that are also ordinary English or markup („Nem", „pl", „nap", „ora", „Csak" never clash, but +// „Fut"/„Perc" would match nothing English either) are kept; a word that DOES collide with English +// copy or markup must be removed here with a reason, never by weakening the mask. +var asciiHuRe = regexp.MustCompile(`(?i)\b(Fut|Nincs|Igen|Nem|Hiba|Mentve|Rendben|Adatok|Napi|Heti|Havi|Mindig|Soha|Tegnap|` + + `Perc|Letiltva|Bekapcsolva|Kikapcsolva|Folyamatban|Sikertelen|Sikeres|Tartalom|` + + `Kapcsolat|Vissza|Megosztva|Kezdeti|Rendszer|Csatlakozva|Kulcs|Megnyit|Elrejt|` + + `Ismeretlen|Szabad|Foglalt|Kijelentkezes|Napok|nap|perc|ora|` + + `Megszakadt|Elavult|Csak|kompatibilis|Rendszermonitor|Most nem|` + + `Folyamat|Titkos|Processzor|Hamarosan|aldomain|adatai|Figyelem|pelda|` + + `Konfig|Megtartva|helyi|pl|Befejezve|automatikus|jelenleg|kedd|szerda|szombat|szint|` + + // release C (R-556): found by eye in JS strings, no test failed on any of them + `jelenlegi|mp|\d+ db)\b|\bmajd a\(z\)`) + +// „db" (darab) only after a number: bare, it is markup (class="db-table", data-has-db) and the English +// abbreviation „DB". The count word the review found was always „ db". + +var htmlTagRe = regexp.MustCompile(`<[^>]*>`) +var urlLikeRe = regexp.MustCompile(`\b[\w-]+(?:\.[\w-]+)+(?:/[\w./-]*)?`) + +// asciiHuWord returns the first ASCII-only Hungarian word in s's COPY, or "". Tags (attribute values +// such as value="helyi" are code, not copy) and host/path tokens (felhom.eu/kapcsolat) are removed +// first; JavaScript string literals stay in, because inline JS copy is page copy. +func asciiHuWord(s string) string { + s = htmlTagRe.ReplaceAllString(s, " ") + s = urlLikeRe.ReplaceAllString(s, " ") + return asciiHuRe.FindString(s) +} + // fixtureStrings collects every string the fixture DATA carries (catalog copy, Go-side messages the // handler would build). Those are not template copy: they are slices 2 and 5 of the plan, and the // English page check subtracts them rather than pretending the template could translate them. @@ -609,6 +661,16 @@ func TestI18nEnglishPages(t *testing.T) { if huLetter("Backup completed at 03:00, next run tomorrow.") { t.Fatal("control: the detector flags plain English") } + // R-565 controls for the ASCII word detector: a planted „mp" in an English value is caught, and a + // plain English sentence is not. + for _, planted := range []string{"label += ' · ' + p.elapsed_sec + ' mp';", "FIGYELEM: the drive", "then majd a(z) app", "3 db"} { + if asciiHuWord(planted) == "" { + t.Fatalf("control: the ASCII detector does not flag ASCII-only Hungarian in %q", planted) + } + } + if w := asciiHuWord(`Backup completed at 03:00, next run tomorrow; 3 items, 12 seconds left. Config + DB + Data, see felhom.eu/kapcsolat`); w != "" { + t.Fatalf("control: the ASCII detector flags plain English (%q)", w) + } for _, c := range i18nCases() { hu := renderI18nCase(t, s, "hu", c) @@ -651,10 +713,20 @@ func TestI18nEnglishPages(t *testing.T) { } stripped = strings.ReplaceAll(stripped, k, "") } + // R-565: a ONE-word data value is not masked above (it could hide template copy), but the ASCII + // detector would read it as copy. Mask it only where it is an element's WHOLE text — exactly + // how data renders (catalog default credentials „nincs"), never inside a sentence. + for _, k := range keys { + if len(strings.Fields(k)) == 1 { + stripped = strings.ReplaceAll(stripped, ">"+k+"<", "><") + } + } // Script/style bodies are included on purpose: inline JS copy is page copy. for i, line := range strings.Split(stripped, "\n") { if huLetter(line) { t.Errorf("%s: Hungarian template copy on the English page, line %d: %q", c.name, i+1, strings.TrimSpace(line)) + } else if w := asciiHuWord(line); w != "" { + t.Errorf("%s: ASCII-only Hungarian word %q on the English page (R-565), line %d: %q", c.name, w, i+1, strings.TrimSpace(line)) } } } diff --git a/controller/internal/web/offsite_restore_list.go b/controller/internal/web/offsite_restore_list.go index 09062ea..f9c4be0 100644 --- a/controller/internal/web/offsite_restore_list.go +++ b/controller/internal/web/offsite_restore_list.go @@ -34,7 +34,7 @@ import ( // offboxMarkerTag is on EVERY off-site snapshot (`restic backup --tag felhom-offbox --tag `), // so it appears in the tag set beside the real app names. It is a marker, not an app, and listing it // would offer the customer a restore of something that does not exist. -const offboxMarkerTag = "felhom-offbox" +const offboxMarkerTag = backup.OffboxMarkerTag // offsiteStoreState is what we know about the repository itself, kept separate from the rows so that // "we could not read it" can never be rendered as "there is nothing in it". diff --git a/controller/internal/web/r256_r257_offbox_refusals_test.go b/controller/internal/web/r256_r257_offbox_refusals_test.go new file mode 100644 index 0000000..efc045f --- /dev/null +++ b/controller/internal/web/r256_r257_offbox_refusals_test.go @@ -0,0 +1,76 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-256 / R-257 (C2 copy sample): two refusals on the remote-backup surface were dead ends. R-256 +// „A mentéskezelő nem elérhető." named an internal component and no route; R-257 „Az offsite tároló +// nincs elárvult állapotban." put an English loanword and the internal state name in front of the +// household. The consequence asserted, through the real handlers: the flash each one redirects with +// renders, in both languages, a sentence that carries a ROUTE (try again / support) and none of the +// internal words. +func TestR256_R257_OffboxRefusalsNameARoute(t *testing.T) { + flashOf := func(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + u, err := url.Parse(w.Header().Get("Location")) + if err != nil || w.Code != http.StatusFound { + t.Fatalf("no redirect: %d %q", w.Code, w.Header().Get("Location")) + } + return u.Query().Get("flash_error") + } + + // R-256: no backup manager. + s := noteServer(t) + w := httptest.NewRecorder() + s.offboxConfigHandler(w, httptest.NewRequest(http.MethodPost, "/backup/offbox/config", nil)) + key := flashOf(t, w) + hu, en := s.msgLang("hu", key), s.msgLang("en", key) + if strings.Contains(hu, "mentéskezelő") || !strings.Contains(hu, "néhány perc múlva") || !strings.Contains(hu, "ügyfélszolgálat") { + t.Errorf("R-256: the Hungarian refusal names no route or still names the component: %q", hu) + } + if strings.Contains(strings.ToLower(en), "backup manager") || !strings.Contains(en, "few minutes") || !strings.Contains(en, "support") { + t.Errorf("R-256: the English refusal names no route or still names the component: %q", en) + } + w = httptest.NewRecorder() + s.offboxVerifyCopyDeleteHandler(w, httptest.NewRequest(http.MethodPost, "/backup/offbox/verify-copy/delete", nil)) + if got := s.msgLang("hu", flashOf(t, w)); got != hu { + t.Errorf("R-256: the restore page's twin refusal differs: %q vs %q", got, hu) + } + + // R-257: a configured remote backup that is NOT orphaned; the reset is refused. + bm := backup.NewManager(s.cfg, s.settings, s.logger) + s.cfg.Paths.DataDir = t.TempDir() + if err := s.settings.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", EscrowState: "escrowed"}); err != nil { + t.Fatal(err) + } + if err := bm.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { + t.Fatal(err) + } + if !bm.OffboxConfigured() || bm.OffboxOrphaned() { + t.Fatalf("setup: configured=%v orphaned=%v", bm.OffboxConfigured(), bm.OffboxOrphaned()) + } + s.backupMgr = bm + w = httptest.NewRecorder() + s.offboxResetHandler(w, httptest.NewRequest(http.MethodPost, "/backup/offbox/reset", nil)) + key = flashOf(t, w) + hu, en = s.msgLang("hu", key), s.msgLang("en", key) + for _, bad := range []string{"offsite", "elárvult"} { + if strings.Contains(hu, bad) { + t.Errorf("R-257: the Hungarian refusal still says %q: %q", bad, hu) + } + } + if !strings.Contains(hu, "ügyfélszolgálat") || !strings.Contains(hu, "nem kell") { + t.Errorf("R-257: the Hungarian refusal does not say why or where to go: %q", hu) + } + if strings.Contains(strings.ToLower(en), "orphan") || !strings.Contains(en, "support") { + t.Errorf("R-257: the English refusal: %q", en) + } +} diff --git a/controller/internal/web/r365_abandon_overdue_test.go b/controller/internal/web/r365_abandon_overdue_test.go new file mode 100644 index 0000000..04039a6 --- /dev/null +++ b/controller/internal/web/r365_abandon_overdue_test.go @@ -0,0 +1,51 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-365: with the abandonment's due moment PASSED but the daily sweep not yet run (up to ~29 h), the +// card read „A kérésed szerint a korábbi távoli mentéseidet 2026-08-20 napján véglegesen töröljük" on +// 2026-08-21 — a past date in the future tense. The consequence asserted, through the real handler and +// a real countdown read from settings: an overdue countdown renders the overdue sentence (due since the +// date, runs at the next daily maintenance) and not the future-tense one; a running countdown still +// renders the future-tense one (negative control). +func TestR365_OverdueCountdownIsNotFutureTense(t *testing.T) { + render := func(t *testing.T, due time.Time) string { + f := newRecoveryFixture(t) + if err := f.sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", + Schedule: "daily", EscrowState: "escrowed", + AbandonAt: due.UTC().Format(time.RFC3339), AbandonStartedAt: due.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339), + }); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + f.s.backupsRemoteHandler(rr, httptest.NewRequest(http.MethodGet, "/backups/remote", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status %d", rr.Code) + } + return rr.Body.String() + } + + overdue := time.Now().Add(-20 * time.Hour) + html := render(t, overdue) + date := overdue.UTC().Format("2006-01-02") + if !strings.Contains(html, "óta esedékes") || !strings.Contains(html, date) { + t.Errorf("R-365: an overdue countdown does not say the deletion is due since %s", date) + } + if strings.Contains(html, "napján véglegesen töröljük") { + t.Errorf("R-365: an overdue countdown still renders a past date in the future tense") + } + + running := render(t, time.Now().Add(3*24*time.Hour)) + if !strings.Contains(running, "napján véglegesen töröljük") || strings.Contains(running, "óta esedékes") { + t.Errorf("a running countdown must keep the future-tense sentence") + } +} diff --git a/controller/internal/web/r365_banner_overdue_test.go b/controller/internal/web/r365_banner_overdue_test.go new file mode 100644 index 0000000..672b87e --- /dev/null +++ b/controller/internal/web/r365_banner_overdue_test.go @@ -0,0 +1,57 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-365 follow-up: at 0 days left the reminder bar's `{{if .RecoveryAbandonDays}}` is false, so a +// countdown whose due moment had PASSED fell back to the undecided-box reminder text — the deletion +// vanished from the bar on the very day it is due — and offered „Ne emlékeztessen újra" over it. The +// consequence asserted through the real data path (addRecoveryBanner, reading a real countdown from +// settings) and the real layout: the bar says the deletion is due since the date, and offers no +// reminder opt-out; a running countdown (negative control) still says „N nap múlva". +func TestR365_BannerSaysDueAtZeroDays(t *testing.T) { + render := func(t *testing.T, due time.Time) string { + f := newRecoveryFixture(t) + if err := f.sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", + Schedule: "daily", EscrowState: "escrowed", + AbandonAt: due.UTC().Format(time.RFC3339), AbandonStartedAt: due.Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339), + }); err != nil { + t.Fatal(err) + } + f.s.recoveryInterrupts() // advances the situation's epoch, exactly as the landing page does + rr := httptest.NewRecorder() + f.s.backupsRemoteHandler(rr, httptest.NewRequest(http.MethodGet, "/backups/remote", nil)) + body := rr.Body.String() + i := strings.Index(body, `alert-banner alert-banner-warning`) + if i < 0 { + t.Fatalf("setup: the reminder bar did not render (status %d)", rr.Code) + } + j := strings.Index(body[i:], "\n") + if j < 0 { + j = len(body) - i + } + return body[i : i+j] + } + + overdue := time.Now().Add(-20 * time.Hour) + bar := render(t, overdue) + if !strings.Contains(bar, "óta esedékes") || !strings.Contains(bar, overdue.UTC().Format("2006-01-02")) { + t.Errorf("R-365: at 0 days the bar does not say the deletion is due:\n%s", bar) + } + if strings.Contains(bar, "remind-optout") { + t.Errorf("R-365: at 0 days the bar offers to stop reminders over a due deletion") + } + + bar = render(t, time.Now().Add(2*24*time.Hour+time.Hour)) + if !strings.Contains(bar, "nap múlva") || strings.Contains(bar, "óta esedékes") { + t.Errorf("a running countdown must keep its „N nap múlva” sentence:\n%s", bar) + } +} diff --git a/controller/internal/web/r567_storage_wizard_nav_test.go b/controller/internal/web/r567_storage_wizard_nav_test.go new file mode 100644 index 0000000..f39cb3e --- /dev/null +++ b/controller/internal/web/r567_storage_wizard_nav_test.go @@ -0,0 +1,27 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" +) + +// R-567: the two drive wizard pages pass their TEMPLATE name as Page, and the sidebar's storage +// group only knew "storage" / "storage-network" — so on /storage/init and /storage/attach the +// Tárhely group was closed and nothing was highlighted, as if the household had left the section. +// Asserted through the real handler and the real layout. +func TestStorageWizardPages_OpenTheStorageNavGroup(t *testing.T) { + s := testServer(t) + s.loadTemplates() + for _, page := range []string{"storage_init", "storage_attach"} { + rr := httptest.NewRecorder() + s.storageWizardPageHandler(rr, httptest.NewRequest("GET", "/storage/init", nil), page) + body := rr.Body.String() + if !strings.Contains(body, `
  • {{T "layout.inditopult"}}
  • {{T "layout.vezerlopult"}}
  • {{T "layout.alkalmazasok"}}
  • - {{$storageOpen := or (eq .Page "storage") (eq .Page "storage-network")}} + {{$storageOpen := or (eq .Page "storage") (eq .Page "storage-network") (eq .Page "storage_init") (eq .Page "storage_attach")}} @@ -139,7 +139,11 @@
    - {{if .RecoveryAbandonDays}} + {{if .RecoveryAbandonOverdue}} + {{/* R-365 follow-up: the due moment has passed; the deletion runs at the next daily sweep. */}} + {{T "layout.abandon_overdue"}} + {{if .RecoveryAbandonRetrievalOffered}}{{T "layout.addig_meg_visszaszerezheted_oket_a"}}{{else}}{{T "layout.hogy_ezek_meg_visszaszerezhetok_e"}}{{end}} + {{else if .RecoveryAbandonDays}} {{/* R-302: the DELETION and its date are certain and always render. The RETRIEVAL clause is conditional on the hub still holding the same sealed package it held when the customer decided — pinned then, compared now. It rendered unconditionally, and was false on a @@ -164,7 +168,7 @@ {{.CSRFField}} - {{if not .RecoveryAbandonDays}} + {{if not (or .RecoveryAbandonDays .RecoveryAbandonOverdue)}}
    {{.CSRFField}} diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html b/controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html new file mode 100644 index 0000000..f378e61 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html @@ -0,0 +1,720 @@ + + + + + + + + Távoli mentés beállítása — Felhom.eu + + + + + + + + +
    + + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + +

    Távoli mentés (3. mentés) — titkosított, offsite

    +

    Az alkalmazás-mentések titkosított másolata egy távoli tárolóra — saját NAS vagy Felhom offsite tárhely — restic + SFTP kapcsolaton. A tároló csak titkosított adatot lát. Ez a 3-2-1 szabály „1 off-site" lába — független a helyi másodpéldánytól és a teljes rendszermentéstől.

    +
    + +
    +
    +
    ✓ Rendben
    +
    Utolsó távoli mentés
    # napja
    +
    +
    +
    5 GB
    +
    Tároló méret · 10 pillanatkép
    +
    +
    +
    felhom@nas.example
    +
    /srv/repo
    +
    +
    + + + +
    +
    Tárhelykeret: 5 GB / 100 GB (5%)
    +
    +
    +
    +
    + + + + + + + + + + + +
    +

    A helyreállítási kód letétbe helyezve.

    + +
    + + + +
    +

    A korábbi mentések törlése folyamatban

    +

    + + A kérésed szerint a korábbi távoli mentéseid törlése 2026-09-30 óta esedékes: a következő napi karbantartáskor véglegesen töröljük őket. + Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, írj nekünk a törlés előtt. +

    + Mégis visszaszerzem a kóddal +
    + + +
    +
    + + +
    + + +

    Mely alkalmazások mentődnek a távoli tárolóra?

    + +

    Nincs telepített alkalmazás.

    + + + +
    + Távoli mentési cél beállítása +
    +
    +
    +
    +
    +
    +
    + + A kulcsot 0600-as fájlba írjuk; sosem naplózzuk és nem tároljuk a beállításokban.
    +
    + + A host-kulcs rögzítése (no blind TOFU). Lekérdezhető: ssh-keyscan -p <port> <host>
    + + +
    +
    + + + + + +
    + + + + diff --git a/controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue.html b/controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue.html new file mode 100644 index 0000000..1d9421e --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue.html @@ -0,0 +1,603 @@ + + + + + + + + Indítópult — Felhom.eu + + + + + + + + +
    + + +
    + + +
    + + + +
    +
    + + + + + A korábbi távoli mentéseid törlése a kérésed szerint 2026-09-29 óta esedékes: a következő napi karbantartáskor véglegesen töröljük őket. + Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, írj nekünk a törlés előtt. + + Megnézem + + +
    + + + + +
    +
    +
    + + + + + + + + + + + +
    +

    Még nincs telepített alkalmazás.

    +

    Alkalmazások telepítése

    +
    + + + + + + + +
    + + + + diff --git a/controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue_offered.html b/controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue_offered.html new file mode 100644 index 0000000..7bfc232 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/launcher_abandon_overdue_offered.html @@ -0,0 +1,603 @@ + + + + + + + + Indítópult — Felhom.eu + + + + + + + + +
    + + +
    + + +
    + + + +
    +
    + + + + + A korábbi távoli mentéseid törlése a kérésed szerint 2026-09-29 óta esedékes: a következő napi karbantartáskor véglegesen töröljük őket. + Addig még visszaszerezheted őket a helyreállítási kóddal. + + Megnézem + + +
    + + + + +
    +
    +
    + + + + + + + + + + + +
    +

    Még nincs telepített alkalmazás.

    +

    Alkalmazások telepítése

    +
    + + + + + + + +
    + + + + diff --git a/controller/internal/web/testdata/i18n_parity/storage_attach.html b/controller/internal/web/testdata/i18n_parity/storage_attach.html index 4a57037..153cc75 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_attach.html +++ b/controller/internal/web/testdata/i18n_parity/storage_attach.html @@ -112,10 +112,10 @@
  • Vezérlőpult
  • Alkalmazások
  • - diff --git a/controller/internal/web/testdata/i18n_parity/storage_init.html b/controller/internal/web/testdata/i18n_parity/storage_init.html index 7e76366..2b91167 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_init.html +++ b/controller/internal/web/testdata/i18n_parity/storage_init.html @@ -112,10 +112,10 @@
  • Vezérlőpult
  • Alkalmazások
  • - diff --git a/controller/scripts/controller_gates.py b/controller/scripts/controller_gates.py index cc9da2e..617ce7d 100644 --- a/controller/scripts/controller_gates.py +++ b/controller/scripts/controller_gates.py @@ -17,6 +17,7 @@ Gates, in order (all must pass; **non-zero exit on any failure**): 7. docker-v every `docker … -v` mount is a named volume or a proven host path 8. debug-routes every debug-page control resolves to a handler, and back (R-400) 9. reuse-refs every path cited by this repo's REUSE.md still resolves + … gofmt every Go file is gofmt-clean (R-454) WHY THIS FILE EXISTS (2026-08-02, closing R-29 leg (a) and half of leg (b)). @@ -93,6 +94,9 @@ GATES = [ # byte for byte. The template half of parity is proved by rendered fixtures; Go-side copy cannot # be, so it is proved structurally here. Fast: stdlib file reads. ("go-parity", os.path.join(SCRIPTS, "i18n_go_parity.py"), [], True, True), + # R-454 — every Go file is gofmt-clean. `go vet` does not check formatting; nothing did. Fast: + # the toolchain's own formatter, no network, no container runtime. + ("gofmt", os.path.join(SCRIPTS, "gofmt_gate.py"), [], True, True), # R-404 — ADVISORY. Reports the golden debt where it is created; never refuses. ("golden-notice", GOLDEN_NOTICE, [REPO], True, False), ] diff --git a/controller/scripts/gofmt_gate.py b/controller/scripts/gofmt_gate.py new file mode 100644 index 0000000..2ea5575 --- /dev/null +++ b/controller/scripts/gofmt_gate.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""gofmt_gate.py — every Go file in this module is gofmt-clean (R-454). + +MEASURED 2026-09-02: five internal/web test files had been gofmt-unclean for an unknown length of +time and nothing noticed — `go vet` does not check formatting and no gate did. By 2026-10-05 the five +were clean and TWELVE others were not: the count only grows while nothing looks, and every `gofmt -l` +someone runs by hand is then noise that hides a real one. + +Run from controller/: python3 scripts/gofmt_gate.py +Exit 0 clean · 1 convicted (lists the files) · 2 inconclusive (gofmt not found — never a pass). + +gofmt is the Go toolchain's own formatter (not a shell utility), found on PATH or under +`go env GOROOT`/bin. Decoy: scripts/test_gate_decoys.py ("gofmt"). +""" +import os +import shutil +import subprocess +import sys + +CTRL = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ROOTS = ["cmd", "internal"] + + +def find_gofmt(): + p = shutil.which("gofmt") + if p: + return p + go = shutil.which("go") + if go: + try: + root = subprocess.check_output([go, "env", "GOROOT"], text=True).strip() + cand = os.path.join(root, "bin", "gofmt") + if os.path.isfile(cand): + return cand + except Exception: + pass + return None + + +def main(): + gofmt = find_gofmt() + if not gofmt: + print("gofmt gate INCONCLUSIVE: gofmt not found on PATH or under `go env GOROOT`/bin") + return 2 + roots = [r for r in ROOTS if os.path.isdir(os.path.join(CTRL, r))] + if not roots: + print("gofmt gate INCONCLUSIVE: none of %s exists under %s" % (ROOTS, CTRL)) + return 2 + p = subprocess.run([gofmt, "-l"] + roots, cwd=CTRL, capture_output=True, text=True) + if p.returncode != 0: + print("gofmt gate INCONCLUSIVE: gofmt exited %d: %s" % (p.returncode, p.stderr.strip()[:400])) + return 2 + bad = [l for l in p.stdout.splitlines() if l.strip()] + if bad: + for f in bad: + print(" not gofmt-clean: %s" % f) + print("GOFMT GATE FAILED: %d file(s) — run `gofmt -w ` (formatting only, no behaviour change)" % len(bad)) + return 1 + print("gofmt gate OK — every Go file under %s is gofmt-clean" % ", ".join(roots)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 12b8cfb..fbcad04 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -149,7 +149,13 @@ "family_gate.msg.signed_in": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", "family_gate.msg.signed_out": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", "family_gate.msg.store_unreadable": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", - "family_gate.msg.wrong": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader." + "family_gate.msg.wrong": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "note.offsite.fail_locked": "BORN AS A KEY (R-104) -- the cause line for a repository lock that survived the self-heal; a NEW sentence, never a Go literal. Pinned in both languages by TestR104_SurvivingLockIsNamed.", + "err.backup.restore_drive_gone": "BORN AS A KEY (R-362) -- names the drive a restore could not reach instead of a raw permission error; a NEW sentence, never a Go literal. Pinned in both languages by TestR362_DetachedDriveIsNamed.", + "restore.refuse.files.second_drive_whole": "BORN AS A KEY (R-675) -- the unit-restore refusal names the second drive's WHOLE restore (decision 26); a NEW sentence, never a Go literal. Pinned in both languages by TestR675_RefusalNamesTheWholeCopy.", + "flash.offbox.mgr_unavailable": "R-256 -- REWORDED on purpose: the old „A mentéskezelő nem …\" named an internal component and no route; it now says what is wrong in plain words and where to go (try again, then support), so byte parity with the base literal cannot hold. Pinned in both languages by TestR256_R257_OffboxRefusalsNameARoute.", + "flash.offbox.mgr_unreachable": "R-256 -- REWORDED on purpose: the old „A mentéskezelő nem …\" named an internal component and no route; it now says what is wrong in plain words and where to go (try again, then support), so byte parity with the base literal cannot hold. Pinned in both languages by TestR256_R257_OffboxRefusalsNameARoute.", + "flash.offbox.not_orphaned": "R-257 -- REWORDED on purpose: the old sentence put an English loanword and the internal state name „elárvult\" in front of the household; it now says why the action does not apply and where to go, so byte parity with the base literal cannot hold. Pinned in both languages by TestR256_R257_OffboxRefusalsNameARoute." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.", @@ -190,7 +196,6 @@ "flash.tier2.saved": "A 2. mentés beállítása elmentve.", "flash.tier2.app_email_off": "Email-küldés kikapcsolva ennél az alkalmazásnál.", "flash.tier2.app_email_on": "Email-küldés bekapcsolva ennél az alkalmazásnál.", - "flash.offbox.mgr_unavailable": "A mentéskezelő nem elérhető.", "flash.offbox.fields_required": "A cél címe, a felhasználó és a tárhely útvonala kötelező.", "flash.offbox.path_absolute": "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", "flash.offbox.config_invalid": [ @@ -220,13 +225,11 @@ "flash.offbox.waiting_for_escrow": "A távoli mentés a kulcs letétbe helyezésére vár.", "flash.offbox.repo_orphaned": "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", "flash.offbox.run_started": "A távoli mentés elindult — az állapot itt frissül.", - "flash.offbox.not_orphaned": "Az offsite tároló nincs elárvult állapotban.", "flash.offbox.needs_confirmation": "A visszaállításhoz megerősítés szükséges.", "flash.offbox.restart_started": "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).", "flash.offbox.restore_started": "A távoli visszaállítás elindult — az állapot itt frissül.", "flash.offbox.full_needs_confirmation": "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", "flash.offbox.full_restore_started": "A teljes visszaállítás elindult — az állapot itt frissül.", - "flash.offbox.mgr_unreachable": "A mentéskezelő nem érhető el.", "flash.offbox.scratch_missing": "Hiányzó ellenőrző másolat.", "flash.offbox.delete_needs_confirmation": "A törlés megerősítés nélkül nem hajtható végre.", "flash.offbox.delete_failed": [ @@ -1155,5 +1158,9 @@ "update.phase.starting": "Indítás az új verzióval…", "update.phase.verifying": "Működés ellenőrzése…", "update.phase.done": "Frissítve", - "update.phase.failed": "A frissítés nem sikerült" + "update.phase.failed": "A frissítés nem sikerült", + "restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ", + "restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.", + "restore.refuse.files.second_drive": "A fájlok a második meghajtó másolatából állíthatók vissza: „Fájlok visszaállítása”.", + "restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót." } diff --git a/controller/scripts/offbox_rename_gate.py b/controller/scripts/offbox_rename_gate.py index 01887f3..656c077 100644 --- a/controller/scripts/offbox_rename_gate.py +++ b/controller/scripts/offbox_rename_gate.py @@ -4,20 +4,45 @@ customer-facing: the productized target is the Storage Box and the tier concept so "NAS-mentés"-style branding must not reappear in the offbox feature's customer strings. Python, not grep: Hungarian multibyte (the Windows-grep false-negative rule). -Scope: ONLY the offbox feature's files. The "Hálózati tárhely" NAS network-storage feature is a +Scope: ONLY the offbox feature's files — discovered by pattern since R-425, see scoped_files(). The "Hálózati tárhely" NAS network-storage feature is a DIFFERENT feature and keeps its device-truthful NAS wording (its files are not scanned). Allowed by construction (no banned token): the intro's "saját NAS vagy Felhom offsite tárhely" and the manual-target form's "NAS vagy SFTP-kiszolgáló" mentions, comments, code identifiers/routes. Run from controller/: python scripts/offbox_rename_gate.py """ -import io, os, sys +import glob, io, json, os, re, sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import i18n_bundle # noqa: E402 + +# R-425 — THE SCOPE IS DISCOVERED, NOT LISTED. The gate used to scan a fixed three-entry FILES list, +# correct when written, and narrower every time the feature grew a file: measured 2026-09-01, a +# `NAS-mentés` in a new `backups_offbox_extra.html` passed. And since localisation the customer copy of +# the listed template lives in the i18n bundle, which no entry named — so the gate had become largely +# hollow, not only narrow. The scope is now, by pattern: +# * every backup-surface template (`templates/**/backups*.html`, `recovery.html`), judged AS IT +# RENDERS in Hungarian (bundle markers expanded), so copy that moved into hu.json is still seen; +# * every off-site Go file (`internal/web/offbox*.go`, `internal/backup/offbox*.go`, not tests); +# * every hu.json value whose KEY one of those Go files names (flash/note/err keys travel by key). +# A new file matching the patterns is scanned the day it is added. Decoys: test_gate_decoys.py. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_CTRL = os.path.dirname(_HERE) +TEMPLATE_GLOBS = ["internal/web/templates/**/backups*.html", "internal/web/templates/**/recovery.html"] +GO_GLOBS = ["internal/web/offbox*.go", "internal/backup/offbox*.go"] +TEMPLATE_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S) +KEY_LIT = re.compile(r'"([a-z][a-z0-9_]*(?:\.[a-z0-9_\-]+)+)"') + + +def scoped_files(): + out = [] + for pats in (TEMPLATE_GLOBS, GO_GLOBS): + for pat in pats: + for f in glob.glob(os.path.join(_CTRL, pat), recursive=True): + if not f.endswith("_test.go"): + out.append(f) + return sorted(set(out)) -FILES = [ - os.path.join("internal", "web", "templates", "backups.html"), - os.path.join("internal", "web", "offbox_handlers.go"), - os.path.join("internal", "backup", "offbox.go"), -] BANNED = [ "NAS-mentés", @@ -37,20 +62,39 @@ def is_comment(line): def main(): + files = scoped_files() + if not files: + print("OFFBOX RENAME GATE INCONCLUSIVE: no offbox file matched %s" % (TEMPLATE_GLOBS + GO_GLOBS)) + sys.exit(2) + hu = json.load(io.open(os.path.join(_CTRL, "internal", "i18n", "locales", "hu.json"), encoding="utf-8")) + sources = [] # (label, text) + named = set() + for path in files: + rel = os.path.relpath(path, _CTRL) + raw = io.open(path, encoding="utf-8").read() + if path.endswith(".html"): + raw = TEMPLATE_COMMENT.sub("", i18n_bundle.expand(raw, "hu")) + else: + named.update(k for k in KEY_LIT.findall(raw) if k in hu) + sources.append((rel, raw)) + for key in sorted(named): + if isinstance(hu[key], str): + sources.append(("hu.json[%s]" % key, hu[key])) total = 0 - for path in FILES: - for lineno, line in enumerate(io.open(path, encoding="utf-8"), 1): + for label, text in sources: + for lineno, line in enumerate(text.splitlines(), 1): if is_comment(line): continue for tok in BANNED: if tok in line: total += 1 - print("%s:%d [%s] %s" % (path, lineno, tok, + print("%s:%d [%s] %s" % (label, lineno, tok, line.strip()[:100].encode("ascii", "backslashreplace").decode())) if total: print("OFFBOX RENAME GATE FAILED: %d customer-facing NAS-branding string(s) remain" % total) sys.exit(1) - print("offbox rename gate OK — Tier-3 is 'Tavoli mentes' everywhere customer-facing") + print("offbox rename gate OK — Tier-3 is 'Tavoli mentes' everywhere customer-facing " + "(%d file(s) + %d bundle value(s) named by them)" % (len(files), len(named))) if __name__ == "__main__": diff --git a/controller/scripts/retrieval_promise_gate.py b/controller/scripts/retrieval_promise_gate.py index 0f2f9b9..5ecf937 100644 --- a/controller/scripts/retrieval_promise_gate.py +++ b/controller/scripts/retrieval_promise_gate.py @@ -53,6 +53,16 @@ GO_SOURCES = [ # records what happens when the guard chases words instead of claims — it misses the next one. STEMS = ["visszaállíthat", "visszaszerezhet", "visszahozhat", "visszanyit"] +# R-564 — THE SPLIT VERB. Hungarian moves the particle after the verb when something is stressed: +# „csak akkor ÁLLÍTHATÓK VISSZA", „csak a hiányzó fájlokat HOZOD VISSZA". The joined stems above never +# see that form, so seven Hungarian sentences went unscanned until their English translations were +# caught by EN_PATTERNS (slice 1 release B). The same claim, the same registration rule. +SPLIT_PATTERNS = [ + r"állíth\w* vissza", + r"(?:hoz|szerez|nyit)\w* vissza", +] +HU_RE = re.compile("|".join([re.escape(s) + r"[a-záéíóöőúüű]*" for s in STEMS] + SPLIT_PATTERNS)) + # (template, substring that identifies the occurrence) -> why it is allowed. # The substring must be specific enough that a DIFFERENT claim in the same file does not match it. ALLOWLIST = { @@ -94,6 +104,28 @@ ALLOWLIST = { ("recovery.html", "a mentéseid visszaszerezhetők, és a törlés elmarad;"): "the abandon CONFIRMATION screen, shown at the moment of the decision. True by construction " "there: the package the hub holds right now is the one about to be pinned. Left alone.", + # R-564 — the split-verb occurrences, found the day SPLIT_PATTERNS learnt them. Each one's English + # twin was registered in slice 1 release B (ALLOWLIST_EN below), with the same reason. + ("backups_escrow.html", "ezzel a kóddal állíthatók vissza"): + "a PRECONDITION, not a promise: it says the remote backups need THIS code, on the page that " + "creates it. It narrows what is possible; it claims nothing about the old store.", + ("backups_remote.html", "A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után"): + "a PRECONDITION: remote backups are restorable after a total failure ONLY if the recovery " + "code exists. It is the reason to create the code, not a claim that a store is retrievable.", + ("backups_restore.html", "utána hozhatod vissza az adatait"): + "the order of two actions on the restore page for an app that is not installed: reinstall " + "first, then restore its data from the copy listed on that same row.", + ("backups_restore_wizard.html", "csak a hiányzó fájlokat hozod vissza"): + "describes the CHOICE the wizard offers next (missing files only, or a full restore) for a " + "verification copy that is already on the box.", + ("backups_restore_wizard.html", "Az alkalmazás beállításait és adatbázisát hozza vissza"): + "describes what the verification restore DOES, into a separate folder, on the page where it " + "is pressed. Nothing about a recovery code.", + ("backups_restore_wizard.html", "nem hozza vissza"): + "a NEGATIVE: says the verification restore does NOT bring back the household's own files.", + ("recovery.html", "melyik alkalmazás mit hozzon vissza"): + "points to the restore page, where the household picks what each app brings back from a " + "store the box has just opened. A route, not a promise.", } # ── ENGLISH (localisation slice 1, R-556) ───────────────────────────────────────────────────────── @@ -131,9 +163,9 @@ ALLOWLIST_EN = { "= backups_remote.html 'Hogy ezek még visszaszerezhetők-e': the QUESTION branch.", ("recovery.html", "your backups can be retrieved, and the deletion is cancelled;"): "= recovery.html 'a mentéseid visszaszerezhetők, és a törlés elmarad;': the confirmation screen.", - # slice 1 release B — English claims whose HUNGARIAN escapes the Hungarian stems (split verbs: - # „állíthatók vissza", „hozod vissza"). The English gate saw them first; the Hungarian blind spot - # is a register row (R-564). Each is registered on its own merits: + # slice 1 release B — English claims whose HUNGARIAN escaped the Hungarian stems (split verbs: + # „állíthatók vissza", „hozod vissza"). The English gate saw them first; since R-564 the Hungarian + # scan sees them too (SPLIT_PATTERNS) and registers each one above. Each is registered on its own merits: ("backups_remote.html", "Remote backups can be restored after a complete failure only if you create the recovery code."): "a PRECONDITION, not a promise: restorable only IF the code exists — true by the escrow design (hu: " "'csak akkor állíthatók vissza … ha létrehozza').", @@ -184,8 +216,8 @@ def scan(): # in 10-localisation.md; the English bundle covers three pages today. raw = open(path, encoding="utf-8").read() text = stripper.sub("", i18n_bundle.expand(raw, "hu") if path.endswith(".html") else raw) - for stem in STEMS: - for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text): + if True: + for m in HU_RE.finditer(text): line = text[: m.start()].count("\n") + 1 # SPAN-based, not window-based. The promise and the cautious disclaimer sit within a # hundred characters of each other in the same paragraph, so a proximity window matches diff --git a/controller/scripts/test_gate_decoys.py b/controller/scripts/test_gate_decoys.py index 1145c6d..3734900 100644 --- a/controller/scripts/test_gate_decoys.py +++ b/controller/scripts/test_gate_decoys.py @@ -47,6 +47,9 @@ COVERS = { "i18n": "an undefined marker key, a pleading English value, a shrinking/growing gap (v0.247.0)", "go-parity": "a Go-side key REWORDED, a key citing text no base literal has, and a converted " "key left out of the map (v0.252.0, R-557)", + "gofmt": "R-454: a planted unformatted .go file in internal/ is convicted; the clean tree passes", + "offbox-rename": "R-425: NAS branding in a NEW backups*.html, and in a bundle value an offbox Go file " + "names; control: the same token in the network-storage feature's copy is accepted", } fails = [] @@ -172,9 +175,49 @@ swapped("retrieval-promise/en", "retrieval_promise_gate.py", EN_JSON, swapped("retrieval-promise/en-ok", "retrieval_promise_gate.py", EN_JSON, _one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Your old backups are listed on the restore page."'), expect="accept") +# R-564: the Hungarian SPLIT verb („állíthatók vissza") is the same claim as the joined stem; a planted +# split-verb promise must convict, and a plain „Vissza" (a back link, no claim) must not. +swapped("retrieval-promise/split-verb", "retrieval_promise_gate.py", HU_JSON, + _one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "A régi mentéseid a kóddal bármikor állíthatók vissza."')) +swapped("retrieval-promise/split-ok", "retrieval_promise_gate.py", HU_JSON, + _one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "Vissza a listához"'), + expect="accept") swapped("secret-markup/bundle", "secret_in_markup_gate.py", EN_JSON, _one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Copy {{.RetrievalPassword}}"')) +# --- offbox-rename (R-425): the scope is discovered by pattern, and bundle copy is judged. --------- +ran += 1 +_extra = os.path.join(TPL, "backups_offbox_extra.html") +io.open(_extra, "w", encoding="utf-8").write(u"

    A NAS-mentés beállítása

    \n") +try: + _rc, _out = gate("offbox_rename_gate.py") +finally: + os.remove(_extra) +if _rc == 0: + fails.append("offbox-rename/new-file: NAS branding in a NEW backups*.html PASSED — the scope is a " + "fixed list again (R-425)\n%s" % _out[-400:]) +else: + print(" ok %-20s decoy rejected" % "offbox-rename/new-file") +swapped("offbox-rename/bundle", "offbox_rename_gate.py", HU_JSON, + _one('"flash.offbox.run_started": "', '"flash.offbox.run_started": "Mentés a NAS-ra: ')) +swapped("offbox-rename/other-feature-ok", "offbox_rename_gate.py", HU_JSON, + _one('"storage_network.mit_kell_beallitani_a_nas": "Mit kell beállítani a NAS-on?"', + '"storage_network.mit_kell_beallitani_a_nas": "Mit kell beállítani a NAS-on? Ez a NAS-ra vonatkozik."'), + expect="accept") + +# --- gofmt (R-454): an unformatted Go file anywhere under internal/ is convicted. ---------------- +ran += 1 +_gf = os.path.join(CTRL, "internal", "zz_gofmt_decoy_tmp.go") +io.open(_gf, "w", encoding="utf-8").write(u"package internal\nfunc decoy( ) { }\n") +try: + _rc, _out = gate("gofmt_gate.py") +finally: + os.remove(_gf) +if _rc != 1 or "zz_gofmt_decoy_tmp.go" not in _out: + fails.append("gofmt: an unformatted planted file was not convicted (rc=%d)\n%s" % (_rc, _out[-400:])) +else: + print(" ok %-20s decoy rejected" % "gofmt") + # --- go-parity (v0.252.0, R-557): a Go-side message key may only carry base-commit text. --- # --- Three shapes, because the gate makes three different claims. --- GO_KEYS = os.path.join(HERE, "i18n_go_keys.json")