R-839: the deploy API refuses an HDD_PATH that is a folder inside a registered drive

Diagnosed: 9202's paperless-ngx app.yaml named <drive>/userdata/paperless-ngx, written by the
2026-09-22 drill harness through POST /api/stacks/<n>/deploy, which accepted it (RefuseAsAppNamespace
matches by prefix). The boot sweep then asked a folder whether it was a mountpoint and HELD the app.
The deploy page offers drive roots only, so a household could not reach this; every other caller
is now refused with a sentence naming the drive (settings.DriveOfSubPath).
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:
2026-10-05 21:52:05 +02:00
parent 0b93e1ad1e
commit 4867ec1804
6 changed files with 101 additions and 0 deletions
@@ -0,0 +1,67 @@
package api
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-839: a deploy whose HDD_PATH names a folder INSIDE a registered drive is refused at the API, so
// the boot sweep can never again hold an app for asking a folder whether it is a mountpoint. Through
// the real deployStack handler. PATH is emptied so a regression that lets the deploy proceed can
// never reach a real docker (tests that reach real docker act on DooPlex).
func TestR839_DeployRefusesASubPathOfADrive(t *testing.T) {
t.Setenv("PATH", "")
lg := log.New(io.Discard, "", 0)
root := t.TempDir()
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
const drive = "/mnt/felhom-drives/scratch_hdd"
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
if err := os.MkdirAll(cfg.Paths.StacksDir, 0o755); err != nil {
t.Fatal(err)
}
m, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, cfg: cfg, sett: sett, logger: lg}
r.classifyFSPath = func(string) string { return "" }
deploy := func(hdd string) (int, string) {
w := httptest.NewRecorder()
body := `{"values":{"HDD_PATH":"` + hdd + `"}}`
r.deployStack(w, httptest.NewRequest(http.MethodPost, "/api/stacks/paperless-ngx/deploy", strings.NewReader(body)), "paperless-ngx")
return w.Code, w.Body.String()
}
code, body := deploy(drive + "/userdata/paperless-ngx")
if code != http.StatusConflict || !strings.Contains(body, "nem egy mappa") || !strings.Contains(body, drive) {
t.Fatalf("R-839: a folder inside a drive must be refused naming the drive; got %d %s", code, body)
}
// Controls: the drive itself and an unregistered path are not this gate's business.
for _, ok := range []string{drive, drive + "/", "/srv/elsewhere"} {
if _, b := deploy(ok); strings.Contains(b, "nem egy mappa") {
t.Errorf("R-839: %q must not be refused as a folder inside a drive: %s", ok, b)
}
}
if d, in := sett.DriveOfSubPath(drive + "x/userdata"); in {
t.Errorf("a sibling path sharing the prefix (%s) is not inside the drive", d)
}
}
+8
View File
@@ -509,6 +509,14 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
return
}
// R-839: HDD_PATH must name the drive itself, never a folder inside it — see
// settings.DriveOfSubPath. The deploy page only offers drive roots; this guards every other caller.
if drive, inside := r.sett.DriveOfSubPath(body.Values["HDD_PATH"]); inside {
r.logger.Printf("[WARN] [api] Deploy refused for %s: HDD_PATH %s is a folder inside the drive %s, not the drive (R-839)", name, body.Values["HDD_PATH"], drive)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.deploy.hdd_path_inside_drive", drive)})
return
}
// `09` §3 decision 36: the app's drive folder already holds its old data → the household chooses.
if handled := r.keptDataAtInstall(w, req, name, body.Values["HDD_PATH"], body.KeptData); handled {
return