v0.76.0: campaign-#3 hardening (settings .bak recovery, restore stack_name validation, quiesce marker quarantine)
S1: corrupt settings.json recovers from .bak / safe-defaults+preserve, no crash-loop. F2: validStackName gates restore + export handlers (reject /,\,..,NUL traversal). S3: corrupt quiesce marker logged + quarantined, not silently dropped. Tests T-S1/F2/S3 + red-proofs. Agent/hub untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,10 @@ type Settings struct {
|
||||
log *log.Logger `json:"-"`
|
||||
debug bool `json:"-"`
|
||||
|
||||
// LoadWarning is set (non-empty, Hungarian) when Load recovered from a corrupt settings.json —
|
||||
// surfaced to the dashboard as a persistent banner. Not persisted.
|
||||
LoadWarning string `json:"-"`
|
||||
|
||||
// Auth
|
||||
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
|
||||
|
||||
@@ -198,7 +202,24 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, s); err != nil {
|
||||
return nil, fmt.Errorf("parsing settings file: %w", err)
|
||||
// CORRUPT primary — never crash-loop. Recover from the last-known-good .bak; failing that,
|
||||
// preserve the corrupt file for forensics and start on safe defaults (recoverable: an empty
|
||||
// PasswordHash falls back to controller.yaml; the storage registry re-discovers on startup).
|
||||
logger.Printf("[ERROR] [settings] primary settings corrupt (%v) — attempting recovery from .bak", err)
|
||||
if bak, berr := os.ReadFile(path + ".bak"); berr == nil {
|
||||
s2 := &Settings{path: path, log: logger}
|
||||
if json.Unmarshal(bak, s2) == nil {
|
||||
logger.Printf("[WARN] [settings] recovered settings from .bak; re-promoting to primary")
|
||||
_ = os.WriteFile(path, bak, 0644) // best-effort promote
|
||||
s2.LoadWarning = "settings.json volt sérült — visszaállítva biztonsági másolatból"
|
||||
s2.migrateResticToRsync()
|
||||
return s2, nil
|
||||
}
|
||||
}
|
||||
corrupt := fmt.Sprintf("%s.corrupt-%d", path, time.Now().Unix())
|
||||
_ = os.Rename(path, corrupt)
|
||||
logger.Printf("[ERROR] [settings] settings unrecoverable — preserved as %s; starting with safe defaults", corrupt)
|
||||
return &Settings{path: path, log: logger, LoadWarning: "settings.json sérült és helyreállíthatatlan — alapértelmezett beállítások"}, nil
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] [settings] Loaded settings from %s", path)
|
||||
@@ -266,6 +287,12 @@ func (s *Settings) save() error {
|
||||
return fmt.Errorf("renaming settings file: %w", err)
|
||||
}
|
||||
|
||||
// last-known-good: written AFTER the primary rename succeeds, so .bak only ever holds settings that
|
||||
// parsed + saved cleanly. Best-effort — a failed .bak must NOT fail the save.
|
||||
if err := os.WriteFile(s.path+".bak", data, 0644); err != nil && s.log != nil {
|
||||
s.log.Printf("[WARN] [settings] could not write .bak: %v", err)
|
||||
}
|
||||
|
||||
if s.debug {
|
||||
s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func discardLog() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
|
||||
// T-S1a: a corrupt primary recovers from the last-known-good .bak (no error, no crash-loop).
|
||||
// Companion: pre-fix Load (return error on parse) → err != nil → this fails.
|
||||
func TestLoad_RecoversFromBak(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(p+".bak", []byte(`{"password_hash":"bak-hash"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(`{"password_hash": BROKEN not json`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(p, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("Load must not error (no crash-loop): %v", err)
|
||||
}
|
||||
if s.GetPasswordHash() != "bak-hash" {
|
||||
t.Fatalf("want recovered hash bak-hash, got %q", s.GetPasswordHash())
|
||||
}
|
||||
if s.LoadWarning == "" {
|
||||
t.Error("expected LoadWarning to be set after recovery")
|
||||
}
|
||||
if b, _ := os.ReadFile(p); !strings.Contains(string(b), "bak-hash") {
|
||||
t.Error("primary should be re-promoted from .bak")
|
||||
}
|
||||
}
|
||||
|
||||
// T-S1b: unrecoverable (corrupt primary, no .bak) → safe defaults, NO error, corrupt file preserved.
|
||||
func TestLoad_UnrecoverableNoBak(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(p, []byte(`{ BROKEN not json`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(p, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("Load must not error (no crash-loop): %v", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil safe-default settings")
|
||||
}
|
||||
if s.LoadWarning == "" {
|
||||
t.Error("expected LoadWarning to be set")
|
||||
}
|
||||
if m, _ := filepath.Glob(p + ".corrupt-*"); len(m) == 0 {
|
||||
t.Error("corrupt file must be preserved as *.corrupt-*")
|
||||
}
|
||||
if _, e := os.Stat(p); e == nil {
|
||||
t.Error("original corrupt primary should have been renamed away")
|
||||
}
|
||||
}
|
||||
|
||||
// T-S1c: save() writes a .bak equal to the primary (last-known-good).
|
||||
func TestSave_WritesBak(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
s, err := Load(p, discardLog()) // no file → defaults
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetPasswordHash("h1"); err != nil { // SetPasswordHash persists via save()
|
||||
t.Fatal(err)
|
||||
}
|
||||
prim, _ := os.ReadFile(p)
|
||||
bak, e := os.ReadFile(p + ".bak")
|
||||
if e != nil {
|
||||
t.Fatalf(".bak missing after save: %v", e)
|
||||
}
|
||||
if string(prim) != string(bak) {
|
||||
t.Error(".bak must equal the primary after save")
|
||||
}
|
||||
}
|
||||
|
||||
// T-S1d: happy path — valid primary loads normally, no warning (regression).
|
||||
func TestLoad_HappyPath(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(p, []byte(`{"password_hash":"good"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(p, discardLog())
|
||||
if err != nil || s.GetPasswordHash() != "good" || s.LoadWarning != "" {
|
||||
t.Fatalf("happy load failed: err=%v hash=%q warn=%q", err, s.GetPasswordHash(), s.LoadWarning)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user