R-126: refuse a .fab export without a password to a network drive (09 decision 128)
An export to a registered network drive (Kind=network) with no bundle password now answers 400 with a household sentence (hu+en); with a password it runs; a local drive is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -74,6 +74,7 @@
|
||||
"app_export.titkositasi_jelszo": "Encryption password",
|
||||
"app_export.valassz_tarolot": "Choose a storage...",
|
||||
"app_export.valaszthato_tartalom": "Optional content:",
|
||||
"app_export.network_drive_needs_password": "A bundle can go onto a network drive (NAS) only with a password, because it also holds the passwords of the app. Set a password, or choose an attached drive.",
|
||||
"app_import.a_feltoltes_megszakadt_ellenorizze_a": "The upload stopped — check the connection, then try again.",
|
||||
"app_import.adatbazis": "Database:",
|
||||
"app_import.adatok": "Data:",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"app_export.titkositasi_jelszo": "Titkosítási jelszó",
|
||||
"app_export.valassz_tarolot": "Válassz tárolót...",
|
||||
"app_export.valaszthato_tartalom": "Választható tartalom:",
|
||||
"app_export.network_drive_needs_password": "Hálózati tárhelyre (NAS) csak jelszóval védett csomagot menthetsz, mert a csomag az alkalmazás jelszavait is tartalmazza. Adj meg jelszót, vagy válassz csatlakoztatott meghajtót.",
|
||||
"app_import.a_feltoltes_megszakadt_ellenorizze_a": "A feltöltés megszakadt — ellenőrizze a kapcsolatot, majd próbálja újra.",
|
||||
"app_import.adatbazis": "Adatbázis:",
|
||||
"app_import.adatok": "Adatok:",
|
||||
|
||||
@@ -204,6 +204,18 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// R-126 (operator ruling 2026-10-05, 09 §3 decision 128): a `.fab` carries the app's secrets in
|
||||
// plaintext unless a bundle password is set, and a network drive is reachable by every device on
|
||||
// the household's LAN. So an export WITHOUT a password to any network drive is refused; with a
|
||||
// password it proceeds. The destination is already known to be registered (isValidDrivePath), so
|
||||
// IsNetworkStoragePath classifies it by its Kind — the only discriminator (see RefuseAsAppNamespace).
|
||||
// Pinned by TestExportStart_NetworkDriveNeedsPassword.
|
||||
if req.Password == "" && s.settings.IsNetworkStoragePath(req.DestDrive) {
|
||||
s.logger.Printf("[INFO] [web] apiExportStart: refused — no password for network drive %q (stack=%s)", req.DestDrive, req.StackName)
|
||||
jsonError(w, s.msg(r, "app_export.network_drive_needs_password"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := s.appExporter.StartExport(appexport.ExportRequest{
|
||||
StackName: req.StackName,
|
||||
DestDrive: req.DestDrive,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-126 (operator ruling 2026-10-05, 09 §3 decision 128): an export WITHOUT a bundle password to a
|
||||
// network drive is refused; WITH a password it runs; a local drive without a password is unchanged.
|
||||
// The assertions are the consequence — whether a .fab lands on the destination — not only the status.
|
||||
func TestExportStart_NetworkDriveNeedsPassword(t *testing.T) {
|
||||
build := func(t *testing.T, kind string) (*Server, *appexport.Exporter, string) {
|
||||
s := testServer(t)
|
||||
s.cfg.Paths.DataDir = t.TempDir()
|
||||
drive := t.TempDir()
|
||||
stackDir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0644)
|
||||
fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK")
|
||||
prov := &fabWebProvider{stackDir: stackDir, stacksDir: t.TempDir(), hddPath: drive,
|
||||
binds: []appbackup.ClassifiedBind{
|
||||
{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/books"}, Class: appbackup.ClassMandatory},
|
||||
}}
|
||||
e := appexport.NewExporter(prov, s.logger, "test")
|
||||
s.appExporter = e
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{Path: drive, Label: "d", Kind: kind, Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.settings.IsNetworkStoragePath(drive); got != (kind == settings.StorageKindNetwork) {
|
||||
t.Fatalf("fixture: IsNetworkStoragePath(%q)=%v for kind %q", drive, got, kind)
|
||||
}
|
||||
return s, e, drive
|
||||
}
|
||||
start := func(s *Server, drive, password string) (*httptest.ResponseRecorder, map[string]interface{}) {
|
||||
body, _ := json.Marshal(map[string]interface{}{"stack_name": "calibre-web", "dest_drive": drive, "password": password})
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/export/start", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.apiExportStart(rr, req)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
return rr, resp
|
||||
}
|
||||
fabCount := func(dir string) int {
|
||||
n := 0
|
||||
filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && strings.HasSuffix(p, ".fab") {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
t.Run("network drive, no password: refused, nothing written", func(t *testing.T) {
|
||||
s, e, drive := build(t, settings.StorageKindNetwork)
|
||||
rr, resp := start(s, drive, "")
|
||||
if rr.Code != http.StatusBadRequest || resp["ok"] != false {
|
||||
t.Fatalf("want 400 ok=false, got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
msg, _ := resp["error"].(string)
|
||||
// ASCII fragments of the Hungarian sentence (positive), and never the raw key (negative).
|
||||
if !strings.Contains(msg, "(NAS)") || !strings.Contains(msg, "jelsz") || strings.Contains(msg, "app_export.") {
|
||||
t.Errorf("refusal text is not the household sentence: %q", msg)
|
||||
}
|
||||
if j := e.GetActiveJob(); j != nil {
|
||||
t.Errorf("an export job started despite the refusal: %v", j.Snapshot())
|
||||
}
|
||||
if n := fabCount(drive); n != 0 {
|
||||
t.Errorf("%d .fab file(s) landed on the network drive without a password", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("network drive, with password: runs", func(t *testing.T) {
|
||||
s, e, drive := build(t, settings.StorageKindNetwork)
|
||||
rr, resp := start(s, drive, "correct horse battery")
|
||||
if rr.Code != http.StatusOK || resp["ok"] != true {
|
||||
t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
waitExportDone(t, e)
|
||||
if n := fabCount(drive); n != 1 {
|
||||
t.Errorf("want 1 .fab on the network drive, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local drive, no password: unchanged", func(t *testing.T) {
|
||||
s, e, drive := build(t, "")
|
||||
rr, resp := start(s, drive, "")
|
||||
if rr.Code != http.StatusOK || resp["ok"] != true {
|
||||
t.Fatalf("want 200 ok=true, got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
waitExportDone(t, e)
|
||||
if n := fabCount(drive); n != 1 {
|
||||
t.Errorf("want 1 .fab on the local drive, got %d", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"banner.missed_backup.suggest": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",
|
||||
"event.backup_catchup_done": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 109) -- the catch-up's NEW timeline line, never a Go literal; sent by Notifier.NotifyBackupCatchUp (wiring pinned by TestR871_CatchUpWiring).",
|
||||
"badge.lifecycle.abandoned": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
|
||||
"app_export.network_drive_needs_password": "BORN AS A KEY (R-126, `09` decision 128) -- a NEW sentence, never a Go literal: the export refusal for a network drive without a bundle password. Pinned in Hungarian by TestExportStart_NetworkDriveNeedsPassword.",
|
||||
"badge.lifecycle.abandoned.title": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
|
||||
"badge.update.ahead.title": "BORN AS A KEY, v0.260.0 (R-524) -- a NEW sentence, never a Go literal, so there is nothing in the base capture to measure it against. Pinned in both languages by TestUpdateBadgeFollowsTheLanguage.",
|
||||
"badge.update.behind": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
|
||||
|
||||
Reference in New Issue
Block a user