diff --git a/api/openapi.json b/api/openapi.json index 39776c9c..fc596b2e 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -755,6 +755,203 @@ }, "type": "object" }, + "FileEntry": { + "additionalProperties": false, + "properties": { + "dir": { + "type": "boolean" + }, + "hidden": { + "type": "boolean" + }, + "mtime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size_bytes": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "name", + "dir", + "size_bytes", + "mtime", + "hidden" + ], + "type": "object" + }, + "FileLocation": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "root", + "path" + ], + "type": "object" + }, + "Files-copyRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Files-copyRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "from": { + "$ref": "#/components/schemas/FileLocation" + }, + "to": { + "$ref": "#/components/schemas/FileLocation" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "Files-deleteRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Files-deleteRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "root", + "path" + ], + "type": "object" + }, + "Files-listRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Files-listRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "root", + "path" + ], + "type": "object" + }, + "Files-mkdirRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Files-mkdirRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + } + }, + "required": [ + "root", + "path" + ], + "type": "object" + }, + "Files-moveRequest": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Files-moveRequest.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "from": { + "$ref": "#/components/schemas/FileLocation" + }, + "to": { + "$ref": "#/components/schemas/FileLocation" + } + }, + "required": [ + "from", + "to" + ], + "type": "object" + }, + "FilesListResponse": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/FilesListResponse.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/FileEntry" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, "FolderElection": { "additionalProperties": false, "properties": { @@ -3261,6 +3458,168 @@ "summary": "Permission/scope plan for installing a catalog app" } }, + "/api/v1/files/copy": { + "post": { + "operationId": "files-copy", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Files-copyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Copy a file or folder" + } + }, + "/api/v1/files/delete": { + "post": { + "operationId": "files-delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Files-deleteRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Delete a file or folder" + } + }, + "/api/v1/files/list": { + "post": { + "operationId": "files-list", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Files-listRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilesListResponse" + } + } + }, + "description": "OK" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "List a directory in the file manager" + } + }, + "/api/v1/files/mkdir": { + "post": { + "operationId": "files-mkdir", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Files-mkdirRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Create a folder" + } + }, + "/api/v1/files/move": { + "post": { + "operationId": "files-move", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Files-moveRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "summary": "Move or rename a file or folder" + } + }, "/api/v1/health": { "get": { "operationId": "list-health-issues", diff --git a/api/openapi.yaml b/api/openapi.yaml index 5815ac92..78a2a745 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -531,6 +531,147 @@ components: format: uri type: string type: object + FileEntry: + additionalProperties: false + properties: + dir: + type: boolean + hidden: + type: boolean + mtime: + type: string + name: + type: string + size_bytes: + format: int64 + type: integer + required: + - name + - dir + - size_bytes + - mtime + - hidden + type: object + FileLocation: + additionalProperties: false + properties: + path: + type: string + root: + type: string + required: + - root + - path + type: object + Files-copyRequest: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/Files-copyRequest.json + format: uri + readOnly: true + type: string + from: + $ref: "#/components/schemas/FileLocation" + to: + $ref: "#/components/schemas/FileLocation" + required: + - from + - to + type: object + Files-deleteRequest: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/Files-deleteRequest.json + format: uri + readOnly: true + type: string + path: + type: string + root: + type: string + required: + - root + - path + type: object + Files-listRequest: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/Files-listRequest.json + format: uri + readOnly: true + type: string + path: + type: string + root: + type: string + required: + - root + - path + type: object + Files-mkdirRequest: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/Files-mkdirRequest.json + format: uri + readOnly: true + type: string + path: + type: string + root: + type: string + required: + - root + - path + type: object + Files-moveRequest: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/Files-moveRequest.json + format: uri + readOnly: true + type: string + from: + $ref: "#/components/schemas/FileLocation" + to: + $ref: "#/components/schemas/FileLocation" + required: + - from + - to + type: object + FilesListResponse: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - https://example.com/schemas/FilesListResponse.json + format: uri + readOnly: true + type: string + entries: + items: + $ref: "#/components/schemas/FileEntry" + type: + - array + - "null" + required: + - entries + type: object FolderElection: additionalProperties: false properties: @@ -2224,6 +2365,105 @@ paths: $ref: "#/components/schemas/ErrorModel" description: Error summary: Permission/scope plan for installing a catalog app + /api/v1/files/copy: + post: + operationId: files-copy + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Files-copyRequest" + required: true + responses: + "204": + description: No Content + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Error + summary: Copy a file or folder + /api/v1/files/delete: + post: + operationId: files-delete + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Files-deleteRequest" + required: true + responses: + "204": + description: No Content + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Error + summary: Delete a file or folder + /api/v1/files/list: + post: + operationId: files-list + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Files-listRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FilesListResponse" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Error + summary: List a directory in the file manager + /api/v1/files/mkdir: + post: + operationId: files-mkdir + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Files-mkdirRequest" + required: true + responses: + "204": + description: No Content + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Error + summary: Create a folder + /api/v1/files/move: + post: + operationId: files-move + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Files-moveRequest" + required: true + responses: + "204": + description: No Content + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Error + summary: Move or rename a file or folder /api/v1/health: get: operationId: list-health-issues diff --git a/cmd/host-agent-real/main.go b/cmd/host-agent-real/main.go index 76e3cd54..5bc35259 100644 --- a/cmd/host-agent-real/main.go +++ b/cmd/host-agent-real/main.go @@ -36,11 +36,20 @@ import ( "github.com/malmoos/malmo/internal/hostagent" "github.com/malmoos/malmo/internal/hostagent/brainlaunch" + "github.com/malmoos/malmo/internal/hostagent/filemgr" "github.com/malmoos/malmo/internal/profile" "github.com/malmoos/malmo/internal/protocol" ) func main() { + // File-worker mode: the file manager re-execs this binary as a child dropped + // to the requesting user's UID to run one filesystem op (filemgr.RunWorker, + // FILES.md # Execution). It must short-circuit before any normal startup — the + // child only performs the op against stdin/stdout and exits. + if len(os.Args) > 1 && os.Args[1] == filemgr.WorkerArg { + os.Exit(filemgr.RunWorker()) + } + sockPath := os.Getenv("MALMO_AGENT_SOCK") if sockPath == "" { sockPath = protocol.SocketPath diff --git a/cmd/host-agent-real/wiring_appliance.go b/cmd/host-agent-real/wiring_appliance.go index 4e01da61..db4dedc5 100644 --- a/cmd/host-agent-real/wiring_appliance.go +++ b/cmd/host-agent-real/wiring_appliance.go @@ -10,6 +10,7 @@ import ( "github.com/malmoos/malmo/internal/hostagent/avahipublisher" "github.com/malmoos/malmo/internal/hostagent/clockhealth" "github.com/malmoos/malmo/internal/hostagent/diskusage" + "github.com/malmoos/malmo/internal/hostagent/filemgr" "github.com/malmoos/malmo/internal/hostagent/healthsource" "github.com/malmoos/malmo/internal/hostagent/journalsource" "github.com/malmoos/malmo/internal/hostagent/netstate" @@ -71,6 +72,15 @@ func buildAgent() (*hostagent.Agent, func()) { a.Reboot = rebootrequired.New() a.System = procsource.New() a.Net = prov + // The in-dashboard file manager runs each op in a child re-exec'd as the + // requesting user's UID (filemgr, FILES.md # Execution). New only fails if the + // executable path can't be resolved; log and leave Files nil (file routes then + // 501) rather than refuse to boot the whole agent over it. + if fm, err := filemgr.New(); err != nil { + slog.Warn("file manager unavailable", "err", err) + } else { + a.Files = fm + } // Align avahi-daemon with the current LAN set once at startup, then keep // it aligned from the NetworkManager watcher. Startup failure is non-fatal: diff --git a/cmd/host-agent-real/wiring_hosted.go b/cmd/host-agent-real/wiring_hosted.go index 7503e919..13def95a 100644 --- a/cmd/host-agent-real/wiring_hosted.go +++ b/cmd/host-agent-real/wiring_hosted.go @@ -3,9 +3,12 @@ package main import ( + "log/slog" + "github.com/malmoos/malmo/internal/hostagent" "github.com/malmoos/malmo/internal/hostagent/clockhealth" "github.com/malmoos/malmo/internal/hostagent/diskusage" + "github.com/malmoos/malmo/internal/hostagent/filemgr" "github.com/malmoos/malmo/internal/hostagent/healthsource" "github.com/malmoos/malmo/internal/hostagent/journalsource" "github.com/malmoos/malmo/internal/hostagent/pamverifier" @@ -65,6 +68,16 @@ func buildAgent() (*hostagent.Agent, func()) { a.DiskSpace = du a.Reboot = rebootrequired.New() a.System = procsource.New() + // The in-dashboard file manager runs each op in a child re-exec'd as the + // requesting user's UID (filemgr, FILES.md # Execution). A hosted box has a + // /home and shared tree too, so it's wired here as well. New only fails if the + // executable path can't be resolved; log and leave Files nil rather than fail + // to boot the agent. + if fm, err := filemgr.New(); err != nil { + slog.Warn("file manager unavailable", "err", err) + } else { + a.Files = fm + } return a, func() {} } diff --git a/cmd/host-agent/main.go b/cmd/host-agent/main.go index acce5bd1..9d1105fb 100644 --- a/cmd/host-agent/main.go +++ b/cmd/host-agent/main.go @@ -126,6 +126,21 @@ func main() { // keeps GET /v1/discovery/state's interfaces field stable regardless of // the dev box's real network. a.Net = hostagent.NewFakeNetState(netstate.LANInterface{Name: "eth0", Index: 2, IPv4: "192.168.1.20"}) + // Back the /v1/files/* family (the in-dashboard file manager) with in-process + // ops as the dev operator — no UID drop, since the dev brain and this agent + // are the same unprivileged operator (mirroring resolve-home). "home" is the + // operator's own home; "shared" is a dev stand-in for /srv/malmo/shared, under + // MALMO_STATE_DIR when set (so make clean wipes it) else ~/.malmo-shared. + home, err := os.UserHomeDir() + if err != nil { + slog.Error("host-agent (fake) resolve home dir", "err", err) + os.Exit(1) + } + sharedBase := filepath.Join(home, ".malmo-shared") + if dir := os.Getenv("MALMO_STATE_DIR"); dir != "" { + sharedBase = filepath.Join(dir, "shared") + } + a.Files = hostagent.NewFakeFileManager(home, sharedBase) mux := http.NewServeMux() a.Mount(mux) diff --git a/docs/architecture.md b/docs/architecture.md index 972db553..a73d5093 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,8 +13,8 @@ one is JavaScript, one is a container we don't write. | Component | Lives in | What it is | Status | |---|---|---|---| | **`malmo-brain`** | `cmd/brain/`, `internal/` | The control-plane daemon. Owns SQLite state, the REST+SSE API, the app lifecycle, and the Caddy config. One Go binary. | Real | -| **`host-agent` (fake)** | `cmd/host-agent/` | Privileged side used in the inner dev loop. Speaks the real `BRAIN_HOST_PROTOCOL.md` wire format over a UNIX socket; the host operations themselves (Avahi, LUKS, PAM, apt) are stubbed in memory. | **Fake** (real wire, canned ops) | -| **`host-agent-real`** | `cmd/host-agent-real/`, `internal/hostagent/` | The real privileged binary. Seam-injected reporters: PAM password verify (`pamverifier`), `/proc` system sampling (`procsource`), disk usage, RAM pressure, journal streaming, service health, reboot-required flag, user manager, system time-zone setter (`timezone`, `timedatectl set-timezone` — the first-run wizard's Step 3, wired in both build profiles). Discovery is real: per-LAN-interface Avahi announcements (`avahipublisher`) driven by the NetworkManager LAN set (`netstate`), with an avahi-daemon.conf allowlist sync and IP-change replay. Seeds the brain's Docker transport then launches the brain container on startup (`brainlaunch`: `EnsureTransport` creates `malmo-ingress` + runs the `docker-socket-proxy`; `Launch` docker-loads the bundled image if absent, lockstep `malmo.protocol.major` OCI-label check, `docker run --restart unless-stopped` on the ingress net with `DOCKER_HOST` at the proxy). Host ops not yet wired: LUKS/TPM, apt, NM configuration (WiFi setup, `/v1/network/*`). A build-tagged slim **`hosted`** profile (`go build -tags hosted`, #204/C1c) compiles the discovery/NetworkManager stack out for the cloud image — `avahipublisher`/`netstate` unwired, no-op publisher, nil `Net` — keeping the same PAM/user-mgmt/health-system/brain-launch seams (`cmd/host-agent-real/wiring_appliance.go` vs `wiring_hosted.go`). | Partial — see "What is not built yet" | +| **`host-agent` (fake)** | `cmd/host-agent/` | Privileged side used in the inner dev loop. Speaks the real `BRAIN_HOST_PROTOCOL.md` wire format over a UNIX socket; the host operations themselves (Avahi, LUKS, PAM, apt) are stubbed in memory. The file manager (`/v1/files/*`) is served in-process as the dev operator (no UID drop), so the Files destination works under `make dev`. | **Fake** (real wire, canned ops) | +| **`host-agent-real`** | `cmd/host-agent-real/`, `internal/hostagent/` | The real privileged binary. Seam-injected reporters: PAM password verify (`pamverifier`), `/proc` system sampling (`procsource`), disk usage, RAM pressure, journal streaming, service health, reboot-required flag, user manager, system time-zone setter (`timezone`, `timedatectl set-timezone` — the first-run wizard's Step 3, wired in both build profiles), and the in-dashboard file manager (`filemgr`, wired in both profiles — each `/v1/files/*` op runs in a child re-exec'd as the requesting user's UID/GID via `SysProcAttr.Credential`, sharing the pure `fileops` primitives with the fake, `FILES.md` # Execution). Discovery is real: per-LAN-interface Avahi announcements (`avahipublisher`) driven by the NetworkManager LAN set (`netstate`), with an avahi-daemon.conf allowlist sync and IP-change replay. Seeds the brain's Docker transport then launches the brain container on startup (`brainlaunch`: `EnsureTransport` creates `malmo-ingress` + runs the `docker-socket-proxy`; `Launch` docker-loads the bundled image if absent, lockstep `malmo.protocol.major` OCI-label check, `docker run --restart unless-stopped` on the ingress net with `DOCKER_HOST` at the proxy). Host ops not yet wired: LUKS/TPM, apt, NM configuration (WiFi setup, `/v1/network/*`). A build-tagged slim **`hosted`** profile (`go build -tags hosted`, #204/C1c) compiles the discovery/NetworkManager stack out for the cloud image — `avahipublisher`/`netstate` unwired, no-op publisher, nil `Net` — keeping the same PAM/user-mgmt/health-system/brain-launch seams (`cmd/host-agent-real/wiring_appliance.go` vs `wiring_hosted.go`). | Partial — see "What is not built yet" | | **Caddy** | `dev/caddy.json`, `dev/docker-compose.yml` | Reverse proxy. Terminates `*.local` (appliance) or `*..malmo.network` over real Let's Encrypt HTTPS (hosted, via a custom acme-dns build) and routes to app containers + the brain. Configured live by the brain via Caddy's admin API. | Real (container) | | **`web-ui`** | `web-ui/` | Vue 3 + Vite + TanStack Query dashboard. Talks only to the brain. Tailwind 4 landed; shadcn-vue scaffolding present, components not yet copied in. Internal code architecture: [`dev/web-ui.md`](dev/web-ui.md). | Real | | **SQLite** | `$STATE_DIR/malmo.db` | The brain's only persistent store. Schema + queries in `internal/store/`. | Real | diff --git a/docs/progress/README.md b/docs/progress/README.md index 4b848f3e..2f9b60d3 100644 --- a/docs/progress/README.md +++ b/docs/progress/README.md @@ -202,3 +202,4 @@ Oldest first; append new entries to the bottom. | [reconcile-pending-recreate.md](reconcile-pending-recreate.md) — Make the reconcile pass converge env-restamping drift on a **running** container (**closes #268**), the gap `manifest-config-block.md` (#264) documented and `RebindMail` shared. `SetConfig`/`RebindMail` are brain-commits-first: store + override/.env, then `compose up -d` a running instance. A failed `compose up` left a container that **kept running** on its old env — reconcile re-created an already-running container only on resource-limit drift, never env drift — so it stayed stale until the user retried (a fallen-over container or brain restart already converged via the "no containers" branch). **Fix:** a `pending_recreate` boolean on `instances` (column + idempotent migration + `Instance.PendingRecreate` + `SetInstancePendingRecreate`). New `recreateRunning(ctx, inst)` helper wraps the running-instance edit's `compose up`: on failure it **marks** the instance pending (the committed override/.env is the reconstructible intent), on success it **clears** the marker; `SetConfig`/`RebindMail` route through it. `Reconcile`'s already-up branch now recreates when resource-limit policy drifted **or** the marker is set — one `compose up -d` converges both (env read at container-create) — and clears the marker on success, with the `restore()` rewind guarded to the resource-stanza patch while the marker (not a file rewind) makes the env recreate retryable; the "no containers" branch also clears the marker after a bring-up so a pending-and-fallen-over instance is satisfied without a redundant later recreate. `Start` also clears the marker on a successful recreate, so a Stop→Start cycle doesn't leave a stale marker for reconcile to redundantly retry (a self-review catch). Covers config + mail + any future env-restamping op in **one place** (not special-cased to config). `APP_LIFECYCLE.md`'s reconcile drift list gained the pending-recreate case as a fourth bullet. Startup-pass cadence unchanged (no timer; converges on next brain start) — matches the issue's "self-healing-on-restart edge" framing and the spec's no-reconciler-loop stance. `make check` green | done | | [hosted-grow-root-disk.md](hosted-grow-root-disk.md) — Grow the hosted image's root filesystem to fill the whole provider disk on boot. The image bakes a fixed **8 GiB** root (sparse raw stays small) but nothing grew it onto the far larger provider disk, so a box ran on ~8 GiB — docker image storage + the brain's SQLite store share that one volume, so a single app install can fill it and the brain's first store write 500s login (the box looks offline though it is up). **Fix:** a runtime `repart.d` definition (`Type=root`, `GrowFileSystem=yes`, no size cap) + a `malmo-grow-root.service` oneshot running `systemd-repart --dry-run=no`, ordered `Before=docker.service host-agent.service` and **fail-closed** (`Requires=` from both, via a docker drop-in and host-agent's unit) so the write-heavy services refuse to start on an un-grown root. Build-time root stays pinned at 8 GiB; the two repart definitions are deliberately separate. Lean set gains `systemd-repart` + `libfdisk1` only (no `libcryptsetup`, cryptsetup cut preserved). Boot-proof asserts the tool is present and the unit reached `active`; real full-disk growth is a provider-box acceptance step. **Known:** the stock `systemd-repart.service` also runs the same config (harmless double execution — idempotent; consolidation deferred pending a live re-test), and a pre-existing unrelated `/setup` 503 boot-proof failure reproduces on clean `main`, tracked separately | done | | [hosted-setup-boot-proof-race.md](hosted-setup-boot-proof-race.md) — Close the `/setup` 503 boot-proof failure [hosted-grow-root-disk.md](hosted-grow-root-disk.md) tracked separately, and confirm the wildcard-TLS/`:443` path is green. The `unseeded` boot polled `POST /api/v1/setup` for **403** but broke on any of `403\|503\|409\|200`, so a transient **503** — Caddy answering "no ready `/api` upstream" in the first second after the control-plane stack comes up, before the brain's listener + dashboard route land — ended the loop and failed the proof, though the box is correct (the brain returns 403 unconditionally on hosted, no 503 path; the diag showed all four containers "Up <1–2 seconds"). **Fix:** break only on a definitive `403\|409\|200` and ride through `502\|503`, exactly as the `/api/v1/me` poll above already does; a genuinely stuck `/setup` still fails after the 30s window. The louder "`:443` never binds" symptom was a **broken-build artifact, not a live regression**: the build-and-boot CI job (`CI / Cloud image`, `publish=false`) passed both the `unseeded` and `seeded` boots (`seeded` hard-asserts `:443` bound + `caddy: wildcard TLS configured`), and every known real-box root cause (`certificates.automate` #301, seed-fetch keep-alive, static resolver) is already fixed in-tree — so no product change. Adds `docs/dev/hosted-boot-proof.md` (runbook: happy-path flow, brain-log milestones, symptom→where-to-look, how to run) and de-stales `TESTING.md`'s `/setup` gate description (the as-built SSO/403, superseding the secret 401/200). Test-lane + docs only | done | +| [in-dashboard-file-manager.md](in-dashboard-file-manager.md) — **Closes #49.** Implements the **Files** dock destination (`FILES.md`) — the last Tier-1 product-surface gap. Full vertical slice in one PR: browse/download/upload/new-folder/rename/move/copy/delete over two roots (**home** `/home//`, **shared** `/srv/malmo/shared/`). New leaf `internal/hostagent/fileops` holds the pure FS primitives (`Resolve`+containment, list/mkdir/move/copy/delete/open/save) shared by the fake (in-process as the dev operator) and the real worker; errors are the `fs.ErrNotExist`/`ErrExist`/`ErrPermission`+`ENOSPC` sentinels, mapped with `errors.Is` at every layer. host-agent gains a `FileManager` seam + `/v1/files/*` handlers (metadata Pattern A + streamed `GET`/`PUT /v1/files/content`, `io.Copy` no-buffer). **The load-bearing piece — `internal/hostagent/filemgr` (Linux-only) runs every op in a child re-exec'd as the requesting user's UID/GID via `SysProcAttr.Credential`** (not in-process `setresuid`, unsafe under Go's M:N scheduler), carrying supplementary groups for the `02770 malmo-shared` tree, spec-over-env (owner-only `/proc`), an `OK`/`ERR` download header, and a `classify`/`reconstruct` error round-trip so the handler's `errors.Is` mapping is identical for real+fake; wired in both build profiles with a `//go:build !linux` stub (mirrors `pamverifier`). `hostclient` gets Pattern A methods + streaming `FilesOpen`/`FilesSave` and a typed `FileOpError` (status-preserving, bypasses `do`'s flattening). Brain `internal/api` adds huma metadata handlers + raw streamed content handlers: session→username (no cross-user browse, any role), `home`/`shared`-only, `..`/absolute rejected, writes gated on `data-drive-missing` (`409 blocked-by-health-issue` w/ `issue_id`), **not audited, no elevation re-prompt** (`FILES.md`). web-ui replaces the `FilesView.vue` stub: root switch, breadcrumbs, folder-first list with per-row actions, XHR-progress upload + ``, a `FileDestinationDialog` folder picker for move/copy. **Known gaps:** the privileged fork is outer-loop-verified only (needs root + real users; the cross-platform `fileops`/worker logic *is* CI-tested); `disk-full` health issue not yet registered (507 stands alone); OpenAPI advertises `ErrorModel` not the custom `{code,message,issue_id}`; no web-ui unit tests (project has no test tooling — typecheck+build gate). `make check` + `make check-web` green | done | diff --git a/docs/progress/in-dashboard-file-manager.md b/docs/progress/in-dashboard-file-manager.md new file mode 100644 index 00000000..be5c7bd8 --- /dev/null +++ b/docs/progress/in-dashboard-file-manager.md @@ -0,0 +1,56 @@ +# In-dashboard file manager (Files destination) + +- **Status:** done +- **Date:** 2026-07-09 +- **Issue:** closes #49 +- **Specs touched:** `FILES.md`, `BRAIN_UI_PROTOCOL.md`, `BRAIN_HOST_PROTOCOL.md` (all pre-existing — this implements the already-written wire contract), `docs/architecture.md` + +Implements the **Files** dock destination specced in `FILES.md` — the last Tier-1 product-surface gap from `NEXT.md`. "Files are first-class" was true on disk from day one but had no in-product browse surface: the only specced path to a user's own content was SMB + a desktop file manager, invisible to the Plex/Synology audience who will never mount a share. This is the zero-setup answer: open the dashboard, see your folders, upload and download from any device with a browser. + +The whole vertical slice landed in one PR (protocol → host-agent fake + real → hostclient → brain → web-ui), per the scoping call recorded below. + +## What was done + +A single thread, `browser → web-ui → brain → host-agent (as the user's UID) → filesystem`, for the v1 op set: list/browse, download, upload, new folder, rename, move, copy, delete over the two roots **home** (`/home//`) and **shared** (`/srv/malmo/shared/`). + +### Shared filesystem primitives — `internal/hostagent/fileops` + +A new leaf package of pure, identity-agnostic FS primitives (`Resolve`, `List`, `Mkdir`, `Move`, `Copy`, `Delete`, `Open`, `Save`) plus lexical path containment. Two consumers justify the shared package (`CLAUDE.md` # no premature abstraction): the fake host-agent runs them in-process; the real host-agent's worker child runs the *same* code as the user's UID. Errors are the plain `fs.ErrNotExist`/`fs.ErrExist`/`fs.ErrPermission` + `syscall.ENOSPC` sentinels, so every layer maps them with `errors.Is` and no bespoke taxonomy. `Move` falls back to copy-then-delete across filesystems (home and shared can be different mounts); move/copy are non-clobbering; delete is permanent (no trash in v1). + +### host-agent seam + handlers — `internal/hostagent` + +- A consumer-side `FileManager` interface and the `/v1/files/*` handlers (`files.go`): metadata ops (Pattern A) plus streamed `GET`/`PUT /v1/files/content` (`io.Copy`, no whole-file buffering). `writeFileErr` maps the fs sentinels to the wire codes (`not-found`/`exists`/`permission-denied`/`no-space`/`invalid-path`/`is-a-directory`). +- `FakeFileManager` (in `fake.go`): runs `fileops` in-process as the dev operator (no UID drop — the dev brain and agent are the same operator, mirroring `resolve-home`), mapping home/shared to two base dirs. Wired by `cmd/host-agent` with the operator's home + a dev shared dir under `MALMO_STATE_DIR`. + +### Real UID-drop file manager — `internal/hostagent/filemgr` (Linux-only) + +The load-bearing security decision (`FILES.md` # Execution, `DECISIONS.md` 2026-05-31). `LinuxFileManager` runs every op in a **child process re-exec'd as the requesting user's UID/GID** via `exec.Cmd.SysProcAttr.Credential` — *not* in-process `setresuid`, which is per-OS-thread and unsafe under Go's M:N scheduler. Running as the user makes POSIX `0750`/`02770` the kernel-enforced backstop (a brain-side bug degrades to "denied," not "leaked"), gives created files correct ownership natively, and contains symlink attacks for free. The supplementary group set is carried (`Credential.Groups`) so the `02770 malmo-shared` tree is writable. The op spec passes to the child via env (`/proc//environ` is owner+root-only, unlike world-readable cmdline); download uses an `OK`/`ERR` header line so a pre-stream failure surfaces as a typed error before bytes flow. A `classify`/`reconstruct` pair round-trips the error class across the process boundary so the handler's `errors.Is` mapping works identically for the real and fake agents. Isolated in its own package (imported only by `cmd/host-agent-real`, wired in both build profiles) with a `//go:build !linux` stub, mirroring `pamverifier`. + +### Brain API — `internal/api` + `internal/hostclient` + +- `hostclient` (`files.go`): Pattern A metadata methods plus streaming `FilesOpen` (returns the response body) and the first request-body-streaming `FilesSave` (pipes the incoming reader through). A typed `FileOpError{Code, Message, Status}` bypasses `do`'s error flattening so the brain can discriminate 404/409/507/403/400, mirroring `ResolveHome`. +- brain (`files.go`): huma metadata handlers + raw streamed content handlers. Resolves the session to a username (every op runs as the session owner — no cross-user browse, for any role), accepts only `home`/`shared`, rejects `..`/absolute before forwarding, and gates writes on the `data-drive-missing` health issue (`409 blocked-by-health-issue`, carrying `issue_id`). A `fileError` StatusError carries the spec's `{code, message, issue_id}` shape. File ops are **not** audited and do **not** trigger the elevation re-prompt (`FILES.md` # Audit & elevation) — deliberately unlike every mutation in `users.go`. The content endpoints are registered raw (outside OpenAPI), already exempt from the request-rate bucket (`ratelimit.go`). + +### web-ui — `web-ui/` + +Replaced the `FilesView.vue` stub with the full view: root switch (My files / Shared), breadcrumb navigation, a folder-first listing with per-row Download/Rename/Move/Copy/Delete, a New-folder inline input, upload with browser-native progress, a "show hidden" toggle, and inline delete confirmation. A `useFiles.ts` composable holds the API layer; download is a same-origin `` (cookie rides along) and upload is an `XMLHttpRequest` PUT (the only transport that reports upload progress) — both outside the JSON `api.ts` wrapper, which can carry neither a streamed File body nor a binary download. A `FileDestinationDialog.vue` folder picker backs move/copy across roots. Types generate from the brain's OpenAPI (`FileEntry`, `FileLocation`). + +## Testing + +- `fileops` — 88.7% (residual: fault-injection-only I/O error branches — mid-copy read faults, cross-device `EXDEV`, close errors). +- host-agent handlers, `hostclient` file methods, and brain handlers — covered end-to-end over a real UNIX socket (the brain test harness mounts a real `hostagent.Agent` + `FakeFileManager`), plus direct unit tests for the error-mapping arms and validation. +- `filemgr` — 51.7%: the worker op logic, error round-trip, `resolve`/`credential`, and command construction are unit-tested; **the privileged fork itself is uncovered by unit tests** because dropping to another UID (and `setgroups`) requires root. See Known gaps. +- `make check` and `make check-web` green. + +## Scope decisions + +- **One full-stack PR** (including the real `filemgr`) rather than a fake-only slice + follow-up, so `Closes #49` means the appliance actually enforces per-user UID isolation — the issue's defining security property, not just a dev-loop demo. +- **No web-ui unit tests.** The project has no web-ui test tooling; adding vitest is a new dependency + CI change beyond this issue's scope. The view is covered by `vue-tsc` typecheck + the production build (the existing `make check-web` gate). Rigorous testing is concentrated on the Go layers. + +## Known gaps / what's next + +- **Real UID-drop path is outer-loop-verified only.** The `filemgr` fork/credential-drop cannot run in CI (needs root + multiple real Linux users + a `/srv/malmo/shared` tree); a booted VM smoke of upload/download + a cross-user denial + a shared-group write is the acceptance step. The cross-platform surface (`fileops`, the worker op logic, error round-trip) *is* CI-tested. +- **`disk-full` health issue is not registered** in `builtinDefinitions()`. The brain maps a host `no-space` to `507` standalone; `FILES.md`'s "linked to the disk-full issue" is aspirational until that issue lands (`HEALTH.md`-owned, out of scope here). +- **OpenAPI advertises `ErrorModel` for the file routes**, not the custom `{code, message, issue_id}` shape huma actually emits for a returned `StatusError` (huma doesn't auto-register the custom type). The web-ui client tolerates both; a shared error-code registry is the `BRAIN_UI_PROTOCOL.md` follow-up. +- **v1 cuts** stand as specced (`FILES.md` # deferred, `NEXT.md`): thumbnails/preview, search, zip download, trash+undo, in-place editing, resumable uploads, sharing links, bulk USB/network import, and the audited admin break-glass into another user's files. +- **Degraded-state surfacing** relies on the global `HealthBanner` (app-wide) plus write-op `409` toasts; a Files-specific empty-tree treatment for `data-drive-missing` was not added. diff --git a/internal/api/api.go b/internal/api/api.go index 6dd1d9a7..20856f10 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -120,6 +120,14 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/v1/system/live", s.systemLive) mux.HandleFunc("GET /api/v1/apps/{id}/log", s.appLog) + // File-manager content transfer is a streamed octet-stream body, not JSON — + // the deliberate ">5s = job" exception (FILES.md # Transfers). Registered raw + // so the brain pipes bytes to/from host-agent without buffering; stays out of + // the OpenAPI surface (the metadata ops in registerFiles carry the typed + // contract). Already exempt from the request-rate bucket (ratelimit.go). + mux.HandleFunc("GET /api/v1/files/content", s.filesDownload) + mux.HandleFunc("PUT /api/v1/files/content", s.filesUpload) + // Catalog assets (icon/screenshots) serve raw image bytes, not JSON, so they // bypass huma and stay out of the OpenAPI surface — the store loads them // directly in tags (APP_STORE.md # Catalog schema). @@ -156,6 +164,7 @@ func (s *Server) registerAll(api huma.API) { s.registerFirstRun(api) s.registerAppSecrets(api) s.registerAppConfig(api) + s.registerFiles(api) } // OpenAPIDocument builds the brain's full REST surface against a throwaway mux diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 1f75dff5..4ae0e8b1 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -19,6 +19,7 @@ import ( "github.com/malmoos/malmo/internal/auth" "github.com/malmoos/malmo/internal/catalog" "github.com/malmoos/malmo/internal/events" + "github.com/malmoos/malmo/internal/hostagent" "github.com/malmoos/malmo/internal/hostclient" "github.com/malmoos/malmo/internal/lifecycle" "github.com/malmoos/malmo/internal/protocol" @@ -62,6 +63,11 @@ type harness struct { // this dir *after* construction and the live server picks them up — no need // to swap the catalog on the already-listening server. catalogDir string + // fileHome / fileShared are the temp dirs the harness's file-manager agent + // serves for root=home / root=shared, so file API tests seed fixtures and + // assert on-disk results. + fileHome string + fileShared string } // srvServer exposes the underlying *Server for tests that exercise handler @@ -156,6 +162,19 @@ func newHarness(t *testing.T, opts ...func(*Server)) *harness { }, }) }) + // File-manager routes are served by a real hostagent.Agent wired with a + // FakeFileManager over temp dirs, mounted under /v1/files/ — so the file API + // tests exercise the actual host-agent handlers + fileops over the socket, + // not a bespoke mock. The two dirs are exposed on the harness so tests can + // seed fixtures and assert on-disk results. + fileHome := t.TempDir() + fileShared := t.TempDir() + fileAgent := hostagent.New(nil, hostagent.NewFakePublisher("")) + fileAgent.Files = hostagent.NewFakeFileManager(fileHome, fileShared) + fileMux := http.NewServeMux() + fileAgent.Mount(fileMux) + mux.Handle("/v1/files/", fileMux) + hostHTTP := &http.Server{Handler: mux} go func() { _ = hostHTTP.Serve(ln) }() t.Cleanup(func() { _ = hostHTTP.Close() }) @@ -189,7 +208,7 @@ func newHarness(t *testing.T, opts ...func(*Server)) *harness { t.Cleanup(ts.Close) jar, _ := newJar() - return &harness{srv: ts, jar: jar, t: t, pwds: pwds, pmu: &pmu, st: st, deleteCalls: &deleteCalls, tzCalls: &tzCalls, apiSrv: srv, catalogDir: catDir} + return &harness{srv: ts, jar: jar, t: t, pwds: pwds, pmu: &pmu, st: st, deleteCalls: &deleteCalls, tzCalls: &tzCalls, apiSrv: srv, catalogDir: catDir, fileHome: fileHome, fileShared: fileShared} } func (h *harness) do(method, path string, body any) *http.Response { diff --git a/internal/api/files.go b/internal/api/files.go new file mode 100644 index 00000000..52d4e080 --- /dev/null +++ b/internal/api/files.go @@ -0,0 +1,333 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "path" + "strings" + + "github.com/danielgtaylor/huma/v2" + + "github.com/malmoos/malmo/internal/auth" + "github.com/malmoos/malmo/internal/hostclient" + "github.com/malmoos/malmo/internal/protocol" +) + +// The in-dashboard file manager (FILES.md, BRAIN_UI_PROTOCOL.md # Files). The +// brain is policy + a transparent byte-pipe: it resolves the session to a user, +// accepts only the two logical roots (home | shared), rejects path traversal, +// and forwards to host-agent's /v1/files/*, which does the real work as the +// user's UID. File ops are NOT audited and do NOT trigger the elevation +// re-prompt — a user acting on their own content is ordinary use (FILES.md # +// Audit & elevation), deliberately unlike every mutation in users.go. + +func (s *Server) registerFiles(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "files-list", Method: "POST", Path: "/api/v1/files/list", + Summary: "List a directory in the file manager", + }, s.filesList) + huma.Register(api, huma.Operation{ + OperationID: "files-mkdir", Method: "POST", Path: "/api/v1/files/mkdir", + Summary: "Create a folder", DefaultStatus: 204, + }, s.filesMkdir) + huma.Register(api, huma.Operation{ + OperationID: "files-move", Method: "POST", Path: "/api/v1/files/move", + Summary: "Move or rename a file or folder", DefaultStatus: 204, + }, s.filesMove) + huma.Register(api, huma.Operation{ + OperationID: "files-copy", Method: "POST", Path: "/api/v1/files/copy", + Summary: "Copy a file or folder", DefaultStatus: 204, + }, s.filesCopy) + huma.Register(api, huma.Operation{ + OperationID: "files-delete", Method: "POST", Path: "/api/v1/files/delete", + Summary: "Delete a file or folder", DefaultStatus: 204, + }, s.filesDelete) +} + +func (s *Server) filesList(ctx context.Context, in *struct { + Body struct { + Root string `json:"root"` + Path string `json:"path"` + } +}) (*struct{ Body protocol.FilesListResponse }, error) { + user, err := s.fileUser(ctx, in.Body.Root, in.Body.Path) + if err != nil { + return nil, err + } + out, err := s.host.FilesList(ctx, user, in.Body.Root, in.Body.Path) + if err != nil { + return nil, mapFileErr(err) + } + if out.Entries == nil { + out.Entries = []protocol.FileEntry{} + } + return &struct{ Body protocol.FilesListResponse }{Body: out}, nil +} + +func (s *Server) filesMkdir(ctx context.Context, in *struct { + Body struct { + Root string `json:"root"` + Path string `json:"path"` + } +}) (*struct{}, error) { + user, err := s.fileWriteUser(ctx, in.Body.Root, in.Body.Path) + if err != nil { + return nil, err + } + if err := s.host.FilesMkdir(ctx, user, in.Body.Root, in.Body.Path); err != nil { + return nil, mapFileErr(err) + } + return &struct{}{}, nil +} + +func (s *Server) filesDelete(ctx context.Context, in *struct { + Body struct { + Root string `json:"root"` + Path string `json:"path"` + } +}) (*struct{}, error) { + user, err := s.fileWriteUser(ctx, in.Body.Root, in.Body.Path) + if err != nil { + return nil, err + } + if err := s.host.FilesDelete(ctx, user, in.Body.Root, in.Body.Path); err != nil { + return nil, mapFileErr(err) + } + return &struct{}{}, nil +} + +func (s *Server) filesMove(ctx context.Context, in *struct { + Body struct { + From protocol.FileLocation `json:"from"` + To protocol.FileLocation `json:"to"` + } +}) (*struct{}, error) { + user, err := s.fileTransferUser(ctx, in.Body.From, in.Body.To) + if err != nil { + return nil, err + } + if err := s.host.FilesMove(ctx, user, in.Body.From, in.Body.To); err != nil { + return nil, mapFileErr(err) + } + return &struct{}{}, nil +} + +func (s *Server) filesCopy(ctx context.Context, in *struct { + Body struct { + From protocol.FileLocation `json:"from"` + To protocol.FileLocation `json:"to"` + } +}) (*struct{}, error) { + user, err := s.fileTransferUser(ctx, in.Body.From, in.Body.To) + if err != nil { + return nil, err + } + if err := s.host.FilesCopy(ctx, user, in.Body.From, in.Body.To); err != nil { + return nil, mapFileErr(err) + } + return &struct{}{}, nil +} + +// filesDownload streams a file to the browser (GET /api/v1/files/content). It is +// registered raw (not huma): a multi-gigabyte transfer is a streamed +// octet-stream body, the deliberate ">5s = job" exception (FILES.md # +// Transfers). The brain pipes bytes from host-agent without buffering. +func (s *Server) filesDownload(w http.ResponseWriter, r *http.Request) { + id, ok := auth.FromContext(r.Context()) + if !ok { + writeUnauthenticated(w) + return + } + root, relPath := r.URL.Query().Get("root"), r.URL.Query().Get("path") + if err := validateFileTarget(root, relPath); err != nil { + writeFileContentError(w, err) + return + } + rc, err := s.host.FilesOpen(r.Context(), id.User.Username, root, relPath) + if err != nil { + writeFileContentError(w, err) + return + } + defer rc.Close() + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=\""+downloadName(relPath)+"\"") + if _, err := io.Copy(w, rc); err != nil { + // Status is already 200 (bytes have flowed); a mid-stream break is a + // client disconnect or a host read fault we can only log. + slog.Warn("file download interrupted", "user", id.User.Username, "root", root, "err", err) + } +} + +// filesUpload streams an upload body to host-agent (PUT /api/v1/files/content). +// Raw, streamed, and health-gated like the write metadata ops. +func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) { + id, ok := auth.FromContext(r.Context()) + if !ok { + writeUnauthenticated(w) + return + } + root, relPath := r.URL.Query().Get("root"), r.URL.Query().Get("path") + if err := validateFileTarget(root, relPath); err != nil { + writeFileContentError(w, err) + return + } + if err := s.blockedByHealth(); err != nil { + writeFileContentError(w, err) + return + } + if err := s.host.FilesSave(r.Context(), id.User.Username, root, relPath, r.Body); err != nil { + writeFileContentError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// fileUser resolves the session to a username and validates the (root, path) for +// a read op. Every file op runs as the session owner — there is no cross-user +// browse for any role (FILES.md # Authorization). +func (s *Server) fileUser(ctx context.Context, root, relPath string) (string, error) { + id, ok := auth.FromContext(ctx) + if !ok { + return "", huma.Error401Unauthorized("unauthenticated") + } + if err := validateFileTarget(root, relPath); err != nil { + return "", err + } + return id.User.Username, nil +} + +// fileWriteUser is fileUser plus the write-blocking health gate (data drive +// missing → the box runs degraded and /home writes are blocked). +func (s *Server) fileWriteUser(ctx context.Context, root, relPath string) (string, error) { + user, err := s.fileUser(ctx, root, relPath) + if err != nil { + return "", err + } + if err := s.blockedByHealth(); err != nil { + return "", err + } + return user, nil +} + +// fileTransferUser validates both endpoints of a move/copy and applies the write +// gate. A transfer may cross roots (home → shared). +func (s *Server) fileTransferUser(ctx context.Context, from, to protocol.FileLocation) (string, error) { + id, ok := auth.FromContext(ctx) + if !ok { + return "", huma.Error401Unauthorized("unauthenticated") + } + if err := validateFileTarget(from.Root, from.Path); err != nil { + return "", err + } + if err := validateFileTarget(to.Root, to.Path); err != nil { + return "", err + } + if err := s.blockedByHealth(); err != nil { + return "", err + } + return id.User.Username, nil +} + +// blockedByHealth returns a 409 blocked-by-health-issue when the data drive is +// enrolled but absent (data-drive-missing → blocks_writes): /home and +// /srv/malmo writes are blocked and the box runs degraded (FILES.md # Failure +// modes, HEALTH.md # blocks_writes). Reads are never gated. Nil health manager +// (some test servers) means no gate. +func (s *Server) blockedByHealth() error { + if s.health == nil { + return nil + } + if iss, ok := s.health.Get("data-drive-missing", ""); ok && iss.BlocksWrites { + return &fileError{status: http.StatusConflict, Code: "blocked-by-health-issue", Message: iss.Summary, IssueID: iss.ID} + } + return nil +} + +// validateFileTarget accepts only the two logical roots and rejects path +// traversal (absolute paths, any ".." segment) before forwarding. host-agent +// re-validates as the UID (the kernel-enforced backstop); this is the +// user-visible policy layer (FILES.md # Authorization). +func validateFileTarget(root, relPath string) error { + if root != "home" && root != "shared" { + return &fileError{status: http.StatusBadRequest, Code: "invalid-root", Message: "root must be home or shared"} + } + if relPath == "" || relPath == "." { + return nil + } + if strings.ContainsRune(relPath, 0) || path.IsAbs(relPath) { + return invalidPathErr() + } + for _, seg := range strings.Split(relPath, "/") { + if seg == ".." { + return invalidPathErr() + } + } + return nil +} + +func invalidPathErr() error { + return &fileError{status: http.StatusBadRequest, Code: "invalid-path", Message: "path escapes its root"} +} + +// downloadName is the browser-facing filename for a download: the last path +// segment, or "download" for an empty/odd path. +func downloadName(relPath string) string { + base := path.Base(relPath) + if base == "." || base == "/" || base == "" { + return "download" + } + // Strip quotes so the Content-Disposition header can't be broken out of. + return strings.NewReplacer("\"", "", "\\", "", "\n", "", "\r", "").Replace(base) +} + +// fileError is a status-carrying wire error for the file surface. huma marshals +// a returned error's own exported fields when it implements StatusError, so the +// dashboard gets the FILES.md {code, message, issue_id?} shape rather than +// huma's detail-carries-code default (BRAIN_UI_PROTOCOL.md # Files). The same +// type serves the raw content handlers via writeFileContentError. +type fileError struct { + status int + Code string `json:"code"` + Message string `json:"message"` + IssueID string `json:"issue_id,omitempty"` +} + +func (e *fileError) Error() string { return e.Message } +func (e *fileError) GetStatus() int { return e.status } + +// fileErrResponse reduces any file-op error to (status, code, message). A host +// error keeps its status/code (400/403/404/409/422/507); a host 5xx (real host +// fault) or an unreachable/decoding error becomes a 502. +func fileErrResponse(err error) (int, string, string) { + var fe *fileError + if errors.As(err, &fe) { + return fe.status, fe.Code, fe.Message + } + var hostErr *hostclient.FileOpError + if errors.As(err, &hostErr) { + if hostErr.Status >= 500 { + return http.StatusBadGateway, "host-agent-error", "host-agent file op failed" + } + return hostErr.Status, hostErr.Code, hostErr.Message + } + return http.StatusBadGateway, "host-agent-error", "host-agent unreachable" +} + +// mapFileErr wraps a downstream file-op error as a StatusError huma can emit. +func mapFileErr(err error) error { + status, code, msg := fileErrResponse(err) + return &fileError{status: status, Code: code, Message: msg} +} + +// writeFileContentError writes the {code, message} envelope for the raw content +// handlers (which sit outside huma's error path). +func writeFileContentError(w http.ResponseWriter, err error) { + status, code, msg := fileErrResponse(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(protocol.Error{Code: code, Message: msg}) +} diff --git a/internal/api/files_test.go b/internal/api/files_test.go new file mode 100644 index 00000000..df00a11f --- /dev/null +++ b/internal/api/files_test.go @@ -0,0 +1,292 @@ +package api + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/malmoos/malmo/internal/health" + "github.com/malmoos/malmo/internal/hostclient" + "github.com/malmoos/malmo/internal/protocol" +) + +// content issues a raw request to the streaming /api/v1/files/content endpoint, +// carrying the harness jar's cookies (the endpoint is not JSON, so the harness's +// do() helper doesn't fit). +func (h *harness) content(method, root, relPath string, body io.Reader) *http.Response { + h.t.Helper() + q := url.Values{"root": {root}, "path": {relPath}} + req, err := http.NewRequest(method, h.srv.URL+"/api/v1/files/content?"+q.Encode(), body) + if err != nil { + h.t.Fatalf("new content request: %v", err) + } + for _, c := range h.jar.Cookies(req.URL) { + req.AddCookie(c) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + h.t.Fatalf("content %s: %v", method, err) + } + return resp +} + +type fileErrBody struct { + Code string `json:"code"` + Message string `json:"message"` + IssueID string `json:"issue_id"` +} + +func TestFilesListAndMkdir(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + if err := os.WriteFile(filepath.Join(h.fileHome, "note.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + resp := h.do("POST", "/api/v1/files/mkdir", map[string]string{"root": "home", "path": "Photos"}) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("mkdir: want 204, got %d", resp.StatusCode) + } + resp.Body.Close() + + resp = h.do("POST", "/api/v1/files/list", map[string]string{"root": "home", "path": ""}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list: want 200, got %d", resp.StatusCode) + } + body := decodeJSON[protocol.FilesListResponse](t, resp) + names := map[string]bool{} + for _, e := range body.Entries { + names[e.Name] = true + } + if !names["note.txt"] || !names["Photos"] { + t.Fatalf("missing entries: %+v", body.Entries) + } +} + +func TestFilesListUnauthenticated(t *testing.T) { + h := newHarness(t) // no login + resp := h.do("POST", "/api/v1/files/list", map[string]string{"root": "home", "path": ""}) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestFilesInvalidRoot(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + resp := h.do("POST", "/api/v1/files/list", map[string]string{"root": "app-state", "path": ""}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("want 400, got %d", resp.StatusCode) + } + if code := decodeJSON[fileErrBody](t, resp).Code; code != "invalid-root" { + t.Fatalf("want invalid-root, got %q", code) + } +} + +func TestFilesPathTraversalRejected(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + resp := h.do("POST", "/api/v1/files/list", map[string]string{"root": "home", "path": "../../etc"}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("want 400, got %d", resp.StatusCode) + } + if code := decodeJSON[fileErrBody](t, resp).Code; code != "invalid-path" { + t.Fatalf("want invalid-path, got %q", code) + } +} + +func TestFilesDeleteNotFound(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + resp := h.do("POST", "/api/v1/files/delete", map[string]string{"root": "home", "path": "gone.txt"}) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("want 404, got %d", resp.StatusCode) + } + if code := decodeJSON[fileErrBody](t, resp).Code; code != "not-found" { + t.Fatalf("want not-found, got %q", code) + } +} + +func TestFilesMkdirExists(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + if err := os.Mkdir(filepath.Join(h.fileHome, "Photos"), 0o755); err != nil { + t.Fatal(err) + } + resp := h.do("POST", "/api/v1/files/mkdir", map[string]string{"root": "home", "path": "Photos"}) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("want 409, got %d", resp.StatusCode) + } + if code := decodeJSON[fileErrBody](t, resp).Code; code != "exists" { + t.Fatalf("want exists, got %q", code) + } +} + +func TestFilesMoveAcrossRoots(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + if err := os.WriteFile(filepath.Join(h.fileHome, "a.txt"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + resp := h.do("POST", "/api/v1/files/move", map[string]any{ + "from": map[string]string{"root": "home", "path": "a.txt"}, + "to": map[string]string{"root": "shared", "path": "a.txt"}, + }) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("move: want 204, got %d", resp.StatusCode) + } + resp.Body.Close() + if got, err := os.ReadFile(filepath.Join(h.fileShared, "a.txt")); err != nil || string(got) != "payload" { + t.Fatalf("moved file wrong: %q err=%v", got, err) + } +} + +func TestFilesCopy(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + if err := os.WriteFile(filepath.Join(h.fileHome, "a.txt"), []byte("dup"), 0o644); err != nil { + t.Fatal(err) + } + resp := h.do("POST", "/api/v1/files/copy", map[string]any{ + "from": map[string]string{"root": "home", "path": "a.txt"}, + "to": map[string]string{"root": "home", "path": "b.txt"}, + }) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("copy: want 204, got %d", resp.StatusCode) + } + resp.Body.Close() + if got, err := os.ReadFile(filepath.Join(h.fileHome, "b.txt")); err != nil || string(got) != "dup" { + t.Fatalf("copied file wrong: %q err=%v", got, err) + } +} + +func TestFilesUploadDownloadRoundtrip(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + + up := h.content(http.MethodPut, "home", "up.txt", bytes.NewReader([]byte("streamed-bytes"))) + if up.StatusCode != http.StatusNoContent { + t.Fatalf("upload: want 204, got %d", up.StatusCode) + } + up.Body.Close() + if got, err := os.ReadFile(filepath.Join(h.fileHome, "up.txt")); err != nil || string(got) != "streamed-bytes" { + t.Fatalf("uploaded file wrong: %q err=%v", got, err) + } + + dl := h.content(http.MethodGet, "home", "up.txt", nil) + if dl.StatusCode != http.StatusOK { + t.Fatalf("download: want 200, got %d", dl.StatusCode) + } + if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" { + t.Fatalf("content-type = %q", ct) + } + if cd := dl.Header.Get("Content-Disposition"); cd != `attachment; filename="up.txt"` { + t.Fatalf("content-disposition = %q", cd) + } + got, _ := io.ReadAll(dl.Body) + dl.Body.Close() + if string(got) != "streamed-bytes" { + t.Fatalf("downloaded body = %q", got) + } +} + +func TestFilesDownloadNotFound(t *testing.T) { + h := newHarness(t) + h.setupAdmin("alex", "pw12345678") + resp := h.content(http.MethodGet, "home", "nope.bin", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("want 404, got %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestFileErrorHelpers(t *testing.T) { + fe := &fileError{status: http.StatusConflict, Code: "exists", Message: "boom"} + if fe.Error() != "boom" || fe.GetStatus() != http.StatusConflict { + t.Fatalf("fileError: Error=%q status=%d", fe.Error(), fe.GetStatus()) + } + + // A *fileError passes through unchanged. + if st, code, _ := fileErrResponse(fe); st != http.StatusConflict || code != "exists" { + t.Fatalf("fileError passthrough: %d %q", st, code) + } + // A host error below 500 keeps its status + code. + if st, code, _ := fileErrResponse(&hostclient.FileOpError{Status: 404, Code: "not-found", Message: "x"}); st != 404 || code != "not-found" { + t.Fatalf("host <500: %d %q", st, code) + } + // A host 5xx (real host fault) becomes a 502. + if st, _, _ := fileErrResponse(&hostclient.FileOpError{Status: 500, Code: "file-op-failed"}); st != http.StatusBadGateway { + t.Fatalf("host 500 → %d, want 502", st) + } + // Anything else (unreachable, decode error) is a 502. + if st, _, _ := fileErrResponse(errors.New("unreachable")); st != http.StatusBadGateway { + t.Fatalf("other → %d, want 502", st) + } + + if got := downloadName("Photos/2024/img.jpg"); got != "img.jpg" { + t.Fatalf("downloadName = %q", got) + } + if got := downloadName(""); got != "download" { + t.Fatalf("empty downloadName = %q", got) + } + if got := downloadName("a/\"quote\".txt"); got != "quote.txt" { + t.Fatalf("quote-stripped downloadName = %q", got) + } +} + +func TestValidateFileTarget(t *testing.T) { + if err := validateFileTarget("home", "Photos/x.jpg"); err != nil { + t.Fatalf("valid: %v", err) + } + if err := validateFileTarget("home", ""); err != nil { + t.Fatalf("empty path: %v", err) + } + if validateFileTarget("bogus", "") == nil { + t.Fatal("bad root should error") + } + if validateFileTarget("home", "../etc") == nil { + t.Fatal("traversal should error") + } + if validateFileTarget("home", "/etc/passwd") == nil { + t.Fatal("absolute should error") + } +} + +func TestFilesWriteBlockedByHealth(t *testing.T) { + h := newHarness(t, func(s *Server) { + hm := health.NewManager(nil) + hm.Raise("data-drive-missing", "", "") + s.health = hm + }) + h.setupAdmin("alex", "pw12345678") + + // A write op (mkdir) is blocked with the health issue surfaced. + resp := h.do("POST", "/api/v1/files/mkdir", map[string]string{"root": "home", "path": "New"}) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("mkdir under degraded box: want 409, got %d", resp.StatusCode) + } + body := decodeJSON[fileErrBody](t, resp) + if body.Code != "blocked-by-health-issue" || body.IssueID != "data-drive-missing" { + t.Fatalf("want blocked-by-health-issue/data-drive-missing, got %+v", body) + } + + // An upload is blocked too. + up := h.content(http.MethodPut, "home", "x.txt", bytes.NewReader([]byte("x"))) + if up.StatusCode != http.StatusConflict { + t.Fatalf("upload under degraded box: want 409, got %d", up.StatusCode) + } + up.Body.Close() + + // A read (list) is NOT gated — the degraded box still browses. + list := h.do("POST", "/api/v1/files/list", map[string]string{"root": "home", "path": ""}) + if list.StatusCode != http.StatusOK { + t.Fatalf("list under degraded box: want 200, got %d", list.StatusCode) + } + list.Body.Close() +} diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index fe710f0f..d5bd209a 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -337,6 +337,14 @@ type Agent struct { // over DBus) vs FakeNetState. When nil, interfaces reports empty — "not // measured", matching the other nil-able reporters. Net NetState + + // Files, when non-nil, backs the /v1/files/* family (the in-dashboard file + // manager, FILES.md). Swapped per binary: FakeFileManager (in-process as the + // dev operator) for cmd/host-agent + tests vs filemgr.LinuxFileManager (a + // child re-exec'd as the requesting user's UID/GID, so POSIX 0750/02770 is + // the kernel-enforced backstop) for cmd/host-agent-real. When nil, every + // /v1/files/* route returns 501. Interface + handlers live in files.go. + Files FileManager } // SystemSampler is a consumer-side interface for the raw system-resources @@ -382,6 +390,13 @@ func (a *Agent) Mount(mux *http.ServeMux) { mux.HandleFunc("GET /v1/identity/well-known", a.wellKnownIdentity) mux.HandleFunc("POST /v1/identity/app-service", a.allocateAppService) mux.HandleFunc("POST /v1/identity/app-service/release", a.releaseAppService) + mux.HandleFunc("POST /v1/files/list", a.filesList) + mux.HandleFunc("POST /v1/files/mkdir", a.filesMkdir) + mux.HandleFunc("POST /v1/files/move", a.filesMove) + mux.HandleFunc("POST /v1/files/copy", a.filesCopy) + mux.HandleFunc("POST /v1/files/delete", a.filesDelete) + mux.HandleFunc("GET /v1/files/content", a.filesDownload) + mux.HandleFunc("PUT /v1/files/content", a.filesUpload) } func (a *Agent) publish(w http.ResponseWriter, r *http.Request) { diff --git a/internal/hostagent/fake.go b/internal/hostagent/fake.go index 01423aa5..567e90cd 100644 --- a/internal/hostagent/fake.go +++ b/internal/hostagent/fake.go @@ -3,9 +3,12 @@ package hostagent import ( "context" "fmt" + "io" + "os" "sync" "time" + "github.com/malmoos/malmo/internal/hostagent/fileops" "github.com/malmoos/malmo/internal/hostagent/netstate" "github.com/malmoos/malmo/internal/protocol" "golang.org/x/crypto/bcrypt" @@ -347,6 +350,112 @@ func NewFakeLogSource(interval time.Duration) *FakeLogSource { return &FakeLogSource{interval: interval} } +// FakeFileManager implements FileManager in-process with no UID drop: the +// dev/test operator runs the ops directly. The dev brain and this agent are the +// same unprivileged operator, so file ownership is already correct — the same +// reasoning devIdentity uses for resolve-home. Root "home" maps to HomeBase and +// "shared" to SharedBase, both ensured to exist on first use so listing an +// empty shared tree returns [] rather than a 404. cmd/host-agent wires the +// operator's real home + a dev shared dir; tests point both at temp dirs. +// +// The user argument is ignored — dev is single-operator, so every user resolves +// to the same bases. The real filemgr.LinuxFileManager is where per-user UID +// resolution and privilege drop live. +type FakeFileManager struct { + HomeBase string + SharedBase string +} + +// NewFakeFileManager returns a FakeFileManager serving home from homeBase and +// shared from sharedBase. +func NewFakeFileManager(homeBase, sharedBase string) *FakeFileManager { + return &FakeFileManager{HomeBase: homeBase, SharedBase: sharedBase} +} + +func (f *FakeFileManager) resolve(root, path string) (string, error) { + var base string + switch root { + case "home": + base = f.HomeBase + case "shared": + base = f.SharedBase + default: + return "", fileops.ErrInvalidPath + } + if err := os.MkdirAll(base, 0o755); err != nil { + return "", err + } + return fileops.Resolve(base, path) +} + +func (f *FakeFileManager) List(_, root, path string) ([]protocol.FileEntry, error) { + abs, err := f.resolve(root, path) + if err != nil { + return nil, err + } + return fileops.List(abs) +} + +func (f *FakeFileManager) Mkdir(_, root, path string) error { + abs, err := f.resolve(root, path) + if err != nil { + return err + } + return fileops.Mkdir(abs) +} + +func (f *FakeFileManager) Delete(_, root, path string) error { + abs, err := f.resolve(root, path) + if err != nil { + return err + } + return fileops.Delete(abs) +} + +func (f *FakeFileManager) Move(_ string, from, to protocol.FileLocation) error { + fromAbs, toAbs, err := f.resolvePair(from, to) + if err != nil { + return err + } + return fileops.Move(fromAbs, toAbs) +} + +func (f *FakeFileManager) Copy(_ string, from, to protocol.FileLocation) error { + fromAbs, toAbs, err := f.resolvePair(from, to) + if err != nil { + return err + } + return fileops.Copy(fromAbs, toAbs) +} + +func (f *FakeFileManager) resolvePair(from, to protocol.FileLocation) (string, string, error) { + fromAbs, err := f.resolve(from.Root, from.Path) + if err != nil { + return "", "", err + } + toAbs, err := f.resolve(to.Root, to.Path) + if err != nil { + return "", "", err + } + return fromAbs, toAbs, nil +} + +func (f *FakeFileManager) Open(_, root, path string) (io.ReadCloser, error) { + abs, err := f.resolve(root, path) + if err != nil { + return nil, err + } + return fileops.Open(abs) +} + +func (f *FakeFileManager) Save(_, root, path string, body io.Reader) error { + abs, err := f.resolve(root, path) + if err != nil { + return err + } + return fileops.Save(abs, body) +} + func (f *FakeLogSource) Follow(ctx context.Context, container string) (<-chan protocol.JournalLine, error) { ch := make(chan protocol.JournalLine) go func() { diff --git a/internal/hostagent/filemgr/filemgr_linux.go b/internal/hostagent/filemgr/filemgr_linux.go new file mode 100644 index 00000000..3617dd18 --- /dev/null +++ b/internal/hostagent/filemgr/filemgr_linux.go @@ -0,0 +1,363 @@ +//go:build linux + +// Package filemgr is the real, privileged host-agent file manager (FILES.md # +// Execution). It implements the hostagent.FileManager seam by running every +// operation in a child process re-exec'd as the requesting user's UID/GID — +// setresuid/setresgid via exec.Cmd.SysProcAttr.Credential, NOT in-process, +// because Go's setuid syscalls are per-OS-thread and unsafe under the M:N +// scheduler. Running as the user makes POSIX 0750/02770 the kernel-enforced +// backstop (a brain-side bug degrades to "denied," not "leaked"), gives created +// files correct ownership natively, and contains symlink attacks for free. +// +// It is isolated in its own package (imported only by cmd/host-agent-real) so +// the shared internal/hostagent package carries no privileged-exec surface, +// mirroring usermgr/pamverifier. The actual filesystem work is the same +// internal/hostagent/fileops primitives the fake runs — the difference is only +// the UID drop and the fork/frame plumbing here. +package filemgr + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + osuser "os/user" + "strconv" + "strings" + "syscall" + + "github.com/malmoos/malmo/internal/hostagent/fileops" + "github.com/malmoos/malmo/internal/protocol" +) + +// WorkerArg is the argv[1] sentinel that puts the host-agent binary into +// file-worker mode. cmd/host-agent-real dispatches it to RunWorker before its +// normal startup. +const WorkerArg = "__fileworker" + +// specEnv carries the JSON-encoded workerSpec to the child. Env (not argv) is +// used because /proc//environ is readable only by the process owner and +// root — the child runs as the user, so its paths don't leak to other local +// users the way a world-readable /proc//cmdline would. +const specEnv = "MALMO_FILEWORKER_SPEC" + +// defaultSharedDir is the household shared tree (STORAGE.md # Permissions). +const defaultSharedDir = "/srv/malmo/shared" + +// workerSpec is the op the parent hands the child via specEnv. Path is the +// resolved absolute target; Path2 is the destination for move/copy. +type workerSpec struct { + Op string `json:"op"` + Path string `json:"path"` + Path2 string `json:"path2,omitempty"` +} + +// workerResult is the child's stdout for metadata/upload ops. ErrKind is empty +// on success; otherwise it names the error class so the parent reconstructs an +// errors.Is-matchable error (the host-agent handler maps it to a wire code). +type workerResult struct { + Entries []protocol.FileEntry `json:"entries,omitempty"` + ErrKind string `json:"err_kind,omitempty"` + ErrMsg string `json:"err_msg,omitempty"` +} + +// LinuxFileManager implements hostagent.FileManager as the requesting user. +type LinuxFileManager struct { + // SharedDir is the shared-tree root (default /srv/malmo/shared). + SharedDir string + // self is the host-agent-real executable path, re-exec'd as the worker. + self string +} + +// New returns a LinuxFileManager that re-execs the current binary as its worker. +func New() (*LinuxFileManager, error) { + self, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("filemgr: resolve executable: %w", err) + } + return &LinuxFileManager{SharedDir: defaultSharedDir, self: self}, nil +} + +func (m *LinuxFileManager) List(user, root, path string) ([]protocol.FileEntry, error) { + abs, cred, err := m.resolve(user, root, path) + if err != nil { + return nil, err + } + res, err := m.runJSON(cred, workerSpec{Op: "list", Path: abs}, nil) + if err != nil { + return nil, err + } + return res.Entries, nil +} + +func (m *LinuxFileManager) Mkdir(user, root, path string) error { + abs, cred, err := m.resolve(user, root, path) + if err != nil { + return err + } + _, err = m.runJSON(cred, workerSpec{Op: "mkdir", Path: abs}, nil) + return err +} + +func (m *LinuxFileManager) Delete(user, root, path string) error { + abs, cred, err := m.resolve(user, root, path) + if err != nil { + return err + } + _, err = m.runJSON(cred, workerSpec{Op: "delete", Path: abs}, nil) + return err +} + +func (m *LinuxFileManager) Move(user string, from, to protocol.FileLocation) error { + fromAbs, toAbs, cred, err := m.resolvePair(user, from, to) + if err != nil { + return err + } + _, err = m.runJSON(cred, workerSpec{Op: "move", Path: fromAbs, Path2: toAbs}, nil) + return err +} + +func (m *LinuxFileManager) Copy(user string, from, to protocol.FileLocation) error { + fromAbs, toAbs, cred, err := m.resolvePair(user, from, to) + if err != nil { + return err + } + _, err = m.runJSON(cred, workerSpec{Op: "copy", Path: fromAbs, Path2: toAbs}, nil) + return err +} + +func (m *LinuxFileManager) Save(user, root, path string, body io.Reader) error { + abs, cred, err := m.resolve(user, root, path) + if err != nil { + return err + } + _, err = m.runJSON(cred, workerSpec{Op: "save", Path: abs}, body) + return err +} + +// Open streams a download from a worker child. The child writes a header line +// ("OK\n" or "ERR\t\t\n") before any bytes, so a pre-stream failure +// (not-found, permission, is-a-directory) surfaces as a typed error and the +// returned ReadCloser only ever carries file bytes. Close reaps the child. +func (m *LinuxFileManager) Open(user, root, path string) (io.ReadCloser, error) { + abs, cred, err := m.resolve(user, root, path) + if err != nil { + return nil, err + } + cmd := m.workerCmd(cred, workerSpec{Op: "open", Path: abs}) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("filemgr open: start worker: %w", err) + } + br := bufio.NewReader(stdout) + header, err := br.ReadString('\n') + if err != nil { + _ = stdout.Close() + _ = cmd.Wait() + return nil, fmt.Errorf("filemgr open: no worker header: %w", err) + } + if kind, msg, isErr := parseErrHeader(header); isErr { + _ = cmd.Wait() + return nil, reconstruct(kind, msg) + } + return &workerReader{r: br, stdout: stdout, cmd: cmd}, nil +} + +// workerReader adapts a worker child's stdout into an io.ReadCloser: reads pull +// file bytes; Close stops the child and reaps it. +type workerReader struct { + r *bufio.Reader + stdout io.ReadCloser + cmd *exec.Cmd +} + +func (w *workerReader) Read(p []byte) (int, error) { return w.r.Read(p) } + +func (w *workerReader) Close() error { + _ = w.stdout.Close() + return w.cmd.Wait() +} + +// resolve looks up the user's identity, resolves the logical root to an absolute +// base, and joins the (containment-checked) relative path. It returns the +// absolute target and the drop-to-user credential for the worker. +func (m *LinuxFileManager) resolve(user, root, relPath string) (string, *syscall.Credential, error) { + cred, home, err := m.credential(user) + if err != nil { + return "", nil, err + } + base, err := m.base(root, home) + if err != nil { + return "", nil, err + } + abs, err := fileops.Resolve(base, relPath) + if err != nil { + return "", nil, err + } + return abs, cred, nil +} + +func (m *LinuxFileManager) resolvePair(user string, from, to protocol.FileLocation) (string, string, *syscall.Credential, error) { + fromAbs, cred, err := m.resolve(user, from.Root, from.Path) + if err != nil { + return "", "", nil, err + } + toAbs, _, err := m.resolve(user, to.Root, to.Path) + if err != nil { + return "", "", nil, err + } + return fromAbs, toAbs, cred, nil +} + +func (m *LinuxFileManager) base(root, home string) (string, error) { + switch root { + case "home": + return home, nil + case "shared": + if m.SharedDir == "" { + return defaultSharedDir, nil + } + return m.SharedDir, nil + default: + return "", fileops.ErrInvalidPath + } +} + +// credential resolves the user's uid/gid, home, and supplementary groups. The +// supplementary set matters: the shared tree is 02770 malmo-shared, so the +// worker must carry the user's group memberships or a shared write would be +// denied. +func (m *LinuxFileManager) credential(user string) (*syscall.Credential, string, error) { + u, err := osuser.Lookup(user) + if err != nil { + var unknown osuser.UnknownUserError + if errors.As(err, &unknown) { + return nil, "", fmt.Errorf("filemgr: %w", fs.ErrNotExist) + } + return nil, "", fmt.Errorf("filemgr: lookup %q: %w", user, err) + } + uid, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return nil, "", fmt.Errorf("filemgr: parse uid %q: %w", u.Uid, err) + } + gid, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return nil, "", fmt.Errorf("filemgr: parse gid %q: %w", u.Gid, err) + } + groupIDs, err := u.GroupIds() + if err != nil { + return nil, "", fmt.Errorf("filemgr: group ids for %q: %w", user, err) + } + groups := make([]uint32, 0, len(groupIDs)) + for _, g := range groupIDs { + n, err := strconv.ParseUint(g, 10, 32) + if err != nil { + return nil, "", fmt.Errorf("filemgr: parse group %q: %w", g, err) + } + groups = append(groups, uint32(n)) + } + return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid), Groups: groups}, u.HomeDir, nil +} + +// workerCmd builds the re-exec command for a worker child with the drop-to-user +// credential and a minimal env carrying only the spec (no parent env leaks to +// the dropped-privilege child). +func (m *LinuxFileManager) workerCmd(cred *syscall.Credential, spec workerSpec) *exec.Cmd { + specJSON, _ := json.Marshal(spec) + cmd := exec.Command(m.self, WorkerArg) + cmd.Env = []string{specEnv + "=" + string(specJSON)} + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred} + return cmd +} + +// runJSON runs a metadata/upload worker and decodes its JSON result, turning a +// worker-reported ErrKind back into an errors.Is-matchable error. stdin is the +// upload body for "save" and nil otherwise. +func (m *LinuxFileManager) runJSON(cred *syscall.Credential, spec workerSpec, stdin io.Reader) (workerResult, error) { + cmd := m.workerCmd(cred, spec) + if stdin != nil { + cmd.Stdin = stdin + } + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return workerResult{}, fmt.Errorf("filemgr %s: worker failed: %w", spec.Op, err) + } + var res workerResult + if err := json.Unmarshal(out.Bytes(), &res); err != nil { + return workerResult{}, fmt.Errorf("filemgr %s: bad worker result: %w", spec.Op, err) + } + if res.ErrKind != "" { + return workerResult{}, reconstruct(res.ErrKind, res.ErrMsg) + } + return res, nil +} + +// classify maps a fileops/os error to a stable kind string for the wire between +// the worker child and the parent. reconstruct is its inverse. +func classify(err error) (kind, msg string) { + msg = err.Error() + switch { + case errors.Is(err, fileops.ErrInvalidPath): + return "invalid-path", msg + case errors.Is(err, fileops.ErrIsDir): + return "is-dir", msg + case errors.Is(err, fs.ErrNotExist): + return "not-found", msg + case errors.Is(err, fs.ErrExist): + return "exists", msg + case errors.Is(err, fs.ErrPermission): + return "permission", msg + case errors.Is(err, syscall.ENOSPC): + return "no-space", msg + default: + return "other", msg + } +} + +// reconstruct rebuilds an errors.Is-matchable error from a worker's kind/msg, so +// the host-agent handler's error mapping (writeFileErr) works identically for +// the real agent and the fake. +func reconstruct(kind, msg string) error { + switch kind { + case "invalid-path": + return fmt.Errorf("%s: %w", msg, fileops.ErrInvalidPath) + case "is-dir": + return fmt.Errorf("%s: %w", msg, fileops.ErrIsDir) + case "not-found": + return fmt.Errorf("%s: %w", msg, fs.ErrNotExist) + case "exists": + return fmt.Errorf("%s: %w", msg, fs.ErrExist) + case "permission": + return fmt.Errorf("%s: %w", msg, fs.ErrPermission) + case "no-space": + return fmt.Errorf("%s: %w", msg, syscall.ENOSPC) + default: + if msg == "" { + msg = "file operation failed" + } + return errors.New(msg) + } +} + +// parseErrHeader reads the worker's download header. "OK\n" → (_, _, false); +// "ERR\t\t\n" → (kind, msg, true). +func parseErrHeader(header string) (kind, msg string, isErr bool) { + header = strings.TrimRight(header, "\n") + rest, ok := strings.CutPrefix(header, "ERR\t") + if !ok { + return "", "", false + } + kind, msg, _ = strings.Cut(rest, "\t") + return kind, msg, true +} diff --git a/internal/hostagent/filemgr/filemgr_linux_test.go b/internal/hostagent/filemgr/filemgr_linux_test.go new file mode 100644 index 00000000..1475bb20 --- /dev/null +++ b/internal/hostagent/filemgr/filemgr_linux_test.go @@ -0,0 +1,306 @@ +//go:build linux + +package filemgr + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "os" + osuser "os/user" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/malmoos/malmo/internal/hostagent/fileops" + "github.com/malmoos/malmo/internal/protocol" +) + +func TestClassifyReconstructRoundTrip(t *testing.T) { + cases := []struct { + name string + err error + want error // the sentinel errors.Is must match after the round-trip + }{ + {"invalid-path", fileops.ErrInvalidPath, fileops.ErrInvalidPath}, + {"is-dir", fileops.ErrIsDir, fileops.ErrIsDir}, + {"not-found", fs.ErrNotExist, fs.ErrNotExist}, + {"exists", fs.ErrExist, fs.ErrExist}, + {"permission", fs.ErrPermission, fs.ErrPermission}, + {"no-space", syscall.ENOSPC, syscall.ENOSPC}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + kind, msg := classify(tc.err) + got := reconstruct(kind, msg) + if !errors.Is(got, tc.want) { + t.Fatalf("round-trip lost the sentinel: kind=%q got=%v", kind, got) + } + }) + } +} + +func TestReconstructOther(t *testing.T) { + got := reconstruct("other", "boom") + if got == nil || got.Error() != "boom" { + t.Fatalf("want error 'boom', got %v", got) + } + if reconstruct("other", "").Error() == "" { + t.Fatal("empty other message should get a default") + } +} + +func runWorkerResult(t *testing.T, spec workerSpec, stdin string) (workerResult, int) { + t.Helper() + var out bytes.Buffer + code := runWorker(spec, strings.NewReader(stdin), &out) + var res workerResult + if err := json.Unmarshal(out.Bytes(), &res); err != nil { + t.Fatalf("decode worker result %q: %v", out.String(), err) + } + return res, code +} + +func TestRunWorkerListAndMkdir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + res, code := runWorkerResult(t, workerSpec{Op: "list", Path: dir}, "") + if code != 0 || res.ErrKind != "" { + t.Fatalf("list: code=%d err=%q", code, res.ErrKind) + } + if len(res.Entries) != 1 || res.Entries[0].Name != "note.txt" { + t.Fatalf("list entries = %+v", res.Entries) + } + + res, _ = runWorkerResult(t, workerSpec{Op: "mkdir", Path: filepath.Join(dir, "New")}, "") + if res.ErrKind != "" { + t.Fatalf("mkdir err = %q", res.ErrKind) + } + if info, err := os.Stat(filepath.Join(dir, "New")); err != nil || !info.IsDir() { + t.Fatalf("dir not created: %v", err) + } +} + +func TestRunWorkerMkdirExists(t *testing.T) { + dir := t.TempDir() + res, _ := runWorkerResult(t, workerSpec{Op: "mkdir", Path: dir}, "") + if res.ErrKind != "exists" { + t.Fatalf("want exists, got %q", res.ErrKind) + } +} + +func TestRunWorkerDeleteNotFound(t *testing.T) { + res, _ := runWorkerResult(t, workerSpec{Op: "delete", Path: filepath.Join(t.TempDir(), "gone")}, "") + if res.ErrKind != "not-found" { + t.Fatalf("want not-found, got %q", res.ErrKind) + } +} + +func TestRunWorkerMoveAndCopy(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "a.txt") + if err := os.WriteFile(src, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + // copy a → b + res, _ := runWorkerResult(t, workerSpec{Op: "copy", Path: src, Path2: filepath.Join(dir, "b.txt")}, "") + if res.ErrKind != "" { + t.Fatalf("copy err = %q", res.ErrKind) + } + // move a → c + res, _ = runWorkerResult(t, workerSpec{Op: "move", Path: src, Path2: filepath.Join(dir, "c.txt")}, "") + if res.ErrKind != "" { + t.Fatalf("move err = %q", res.ErrKind) + } + if _, err := os.Stat(filepath.Join(dir, "c.txt")); err != nil { + t.Fatalf("moved file missing: %v", err) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("source still present after move") + } +} + +func TestRunWorkerSave(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "up.txt") + res, code := runWorkerResult(t, workerSpec{Op: "save", Path: dst}, "streamed") + if code != 0 || res.ErrKind != "" { + t.Fatalf("save: code=%d err=%q", code, res.ErrKind) + } + if got, err := os.ReadFile(dst); err != nil || string(got) != "streamed" { + t.Fatalf("saved content = %q err=%v", got, err) + } +} + +func TestRunWorkerOpen(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "movie.bin") + if err := os.WriteFile(f, []byte("the-bytes"), 0o644); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if code := runWorker(workerSpec{Op: "open", Path: f}, nil, &out); code != 0 { + t.Fatalf("open: code=%d", code) + } + body, ok := strings.CutPrefix(out.String(), "OK\n") + if !ok { + t.Fatalf("missing OK header: %q", out.String()) + } + if body != "the-bytes" { + t.Fatalf("body = %q", body) + } +} + +func TestRunWorkerOpenNotFound(t *testing.T) { + var out bytes.Buffer + runWorker(workerSpec{Op: "open", Path: filepath.Join(t.TempDir(), "nope")}, nil, &out) + kind, _, isErr := parseErrHeader(out.String()) + if !isErr || kind != "not-found" { + t.Fatalf("want ERR not-found header, got %q", out.String()) + } +} + +func TestRunWorkerUnknownOp(t *testing.T) { + var out bytes.Buffer + if code := runWorker(workerSpec{Op: "frobnicate"}, nil, &out); code != 2 { + t.Fatalf("want exit 2, got %d", code) + } +} + +func TestParseErrHeader(t *testing.T) { + if _, _, isErr := parseErrHeader("OK\n"); isErr { + t.Fatal("OK should not parse as error") + } + kind, msg, isErr := parseErrHeader("ERR\tno-space\tno space left\n") + if !isErr || kind != "no-space" || msg != "no space left" { + t.Fatalf("got kind=%q msg=%q isErr=%v", kind, msg, isErr) + } +} + +func TestResolveRejectsBadInput(t *testing.T) { + me, err := osuser.Current() + if err != nil { + t.Skipf("no current user: %v", err) + } + m := &LinuxFileManager{SharedDir: t.TempDir()} + + if _, _, err := m.resolve(me.Username, "app-state", "x"); !errors.Is(err, fileops.ErrInvalidPath) { + t.Fatalf("bad root: want ErrInvalidPath, got %v", err) + } + if _, _, err := m.resolve(me.Username, "home", "../../etc"); !errors.Is(err, fileops.ErrInvalidPath) { + t.Fatalf("traversal: want ErrInvalidPath, got %v", err) + } +} + +func TestCredentialUnknownUser(t *testing.T) { + m := &LinuxFileManager{} + if _, _, err := m.credential("definitely-no-such-user-9f3c"); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +func TestCredentialResolvesCurrentUser(t *testing.T) { + me, err := osuser.Current() + if err != nil { + t.Skipf("no current user: %v", err) + } + m := &LinuxFileManager{} + cred, home, err := m.credential(me.Username) + if err != nil { + t.Fatalf("credential: %v", err) + } + if home != me.HomeDir { + t.Fatalf("home = %q, want %q", home, me.HomeDir) + } + if cred.Uid != uint32(mustAtoi(t, me.Uid)) { + t.Fatalf("uid = %d, want %s", cred.Uid, me.Uid) + } +} + +func TestNew(t *testing.T) { + m, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + if m.self == "" { + t.Fatal("self executable path empty") + } + if m.SharedDir != defaultSharedDir { + t.Fatalf("SharedDir = %q, want %q", m.SharedDir, defaultSharedDir) + } +} + +func TestWorkerCmd(t *testing.T) { + m := &LinuxFileManager{self: "/opt/malmo/host-agent"} + cred := &syscall.Credential{Uid: 3001, Gid: 3001, Groups: []uint32{100, 3001}} + cmd := m.workerCmd(cred, workerSpec{Op: "list", Path: "/home/alex/Photos"}) + + if cmd.Path != "/opt/malmo/host-agent" { + t.Fatalf("cmd.Path = %q", cmd.Path) + } + if len(cmd.Args) != 2 || cmd.Args[1] != WorkerArg { + t.Fatalf("cmd.Args = %v", cmd.Args) + } + if cmd.SysProcAttr == nil || cmd.SysProcAttr.Credential != cred { + t.Fatal("credential not set on SysProcAttr") + } + // The env carries exactly the spec, and it decodes back to what we passed. + var specLine string + for _, e := range cmd.Env { + if v, ok := strings.CutPrefix(e, specEnv+"="); ok { + specLine = v + } + } + if specLine == "" { + t.Fatalf("spec env not found in %v", cmd.Env) + } + var spec workerSpec + if err := json.Unmarshal([]byte(specLine), &spec); err != nil { + t.Fatalf("spec env not valid JSON: %v", err) + } + if spec.Op != "list" || spec.Path != "/home/alex/Photos" { + t.Fatalf("decoded spec = %+v", spec) + } +} + +func TestResolvePair(t *testing.T) { + me, err := osuser.Current() + if err != nil { + t.Skipf("no current user: %v", err) + } + shared := t.TempDir() + m := &LinuxFileManager{SharedDir: shared} + fromAbs, toAbs, cred, err := m.resolvePair( + me.Username, + protocol.FileLocation{Root: "home", Path: "a.txt"}, + protocol.FileLocation{Root: "shared", Path: "b.txt"}, + ) + if err != nil { + t.Fatalf("resolvePair: %v", err) + } + if !strings.HasPrefix(fromAbs, me.HomeDir) { + t.Fatalf("fromAbs = %q, want under %q", fromAbs, me.HomeDir) + } + if toAbs != filepath.Join(shared, "b.txt") { + t.Fatalf("toAbs = %q", toAbs) + } + if cred == nil { + t.Fatal("nil credential") + } +} + +func mustAtoi(t *testing.T, s string) int { + t.Helper() + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + t.Fatalf("non-numeric id %q", s) + } + n = n*10 + int(c-'0') + } + return n +} diff --git a/internal/hostagent/filemgr/filemgr_other.go b/internal/hostagent/filemgr/filemgr_other.go new file mode 100644 index 00000000..b2e15323 --- /dev/null +++ b/internal/hostagent/filemgr/filemgr_other.go @@ -0,0 +1,45 @@ +//go:build !linux + +// Package filemgr — non-Linux stub. The real file manager needs Linux +// privilege-drop (setresuid via SysProcAttr.Credential), so on other platforms +// this stub keeps the package importable (cmd/host-agent-real compiles +// everywhere) while every operation reports unavailability. cmd/host-agent-real +// is only ever run on Linux; this exists purely so the cross-platform surface +// builds, mirroring pamverifier's _other.go. +package filemgr + +import ( + "errors" + "io" + + "github.com/malmoos/malmo/internal/protocol" +) + +// WorkerArg matches the Linux constant so the argv dispatch in cmd/host-agent-real +// compiles on every platform. +const WorkerArg = "__fileworker" + +var errUnsupported = errors.New("filemgr is not available on this platform (requires linux)") + +// LinuxFileManager is a stub on non-Linux builds. +type LinuxFileManager struct{} + +// New reports unavailability on non-Linux builds. +func New() (*LinuxFileManager, error) { return nil, errUnsupported } + +// RunWorker is a no-op worker on non-Linux builds. +func RunWorker() int { return 2 } + +func (*LinuxFileManager) List(_, _, _ string) ([]protocol.FileEntry, error) { + return nil, errUnsupported +} +func (*LinuxFileManager) Mkdir(_, _, _ string) error { return errUnsupported } +func (*LinuxFileManager) Delete(_, _, _ string) error { return errUnsupported } +func (*LinuxFileManager) Move(_ string, _, _ protocol.FileLocation) error { + return errUnsupported +} +func (*LinuxFileManager) Copy(_ string, _, _ protocol.FileLocation) error { + return errUnsupported +} +func (*LinuxFileManager) Open(_, _, _ string) (io.ReadCloser, error) { return nil, errUnsupported } +func (*LinuxFileManager) Save(_, _, _ string, _ io.Reader) error { return errUnsupported } diff --git a/internal/hostagent/filemgr/worker_linux.go b/internal/hostagent/filemgr/worker_linux.go new file mode 100644 index 00000000..73935686 --- /dev/null +++ b/internal/hostagent/filemgr/worker_linux.go @@ -0,0 +1,95 @@ +//go:build linux + +package filemgr + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/malmoos/malmo/internal/hostagent/fileops" +) + +// RunWorker is the child entry point (argv[1] == WorkerArg). It runs as the +// requesting user's UID/GID — the parent dropped privilege via +// SysProcAttr.Credential — so every fileops call is kernel-checked against the +// user's own permissions. It reads the op from specEnv, runs it against os.Stdin +// / os.Stdout, and returns a process exit code. Errors are reported in-band +// (JSON ErrKind for metadata/upload, an ERR header for download), so a failed +// op still exits 0; a non-zero exit means the worker itself broke. +func RunWorker() int { + var spec workerSpec + if err := json.Unmarshal([]byte(os.Getenv(specEnv)), &spec); err != nil { + fmt.Fprintf(os.Stderr, "fileworker: bad spec: %v\n", err) + return 2 + } + return runWorker(spec, os.Stdin, os.Stdout) +} + +// runWorker is the testable core: it executes spec reading from stdin and +// writing to stdout, with no reliance on the process environment. Running it +// directly (no fork, no UID drop) exercises the op + framing logic; the real +// privilege drop is verified in the outer VM loop. +func runWorker(spec workerSpec, stdin io.Reader, stdout io.Writer) int { + switch spec.Op { + case "list": + entries, err := fileops.List(spec.Path) + return writeResult(stdout, workerResult{Entries: entries}, err) + case "mkdir": + return writeResult(stdout, workerResult{}, fileops.Mkdir(spec.Path)) + case "delete": + return writeResult(stdout, workerResult{}, fileops.Delete(spec.Path)) + case "move": + return writeResult(stdout, workerResult{}, fileops.Move(spec.Path, spec.Path2)) + case "copy": + return writeResult(stdout, workerResult{}, fileops.Copy(spec.Path, spec.Path2)) + case "save": + return writeResult(stdout, workerResult{}, fileops.Save(spec.Path, stdin)) + case "open": + return runOpen(spec.Path, stdout) + default: + fmt.Fprintf(os.Stderr, "fileworker: unknown op %q\n", spec.Op) + return 2 + } +} + +// writeResult encodes a metadata/upload result to stdout, folding any op error +// into ErrKind/ErrMsg. It always returns exit 0 — the error is in the JSON, not +// the exit code. +func writeResult(stdout io.Writer, res workerResult, err error) int { + if err != nil { + res.ErrKind, res.ErrMsg = classify(err) + } + _ = json.NewEncoder(stdout).Encode(res) + return 0 +} + +// runOpen streams a download: an "OK\n" header then the file bytes, or an +// "ERR\t\t\n" header if the file can't be opened. The header lets the +// parent surface a typed pre-stream error while the body stays pure bytes. +func runOpen(path string, stdout io.Writer) int { + rc, err := fileops.Open(path) + if err != nil { + kind, msg := classify(err) + fmt.Fprintf(stdout, "ERR\t%s\t%s\n", kind, sanitizeHeader(msg)) + return 0 + } + defer rc.Close() + if _, err := io.WriteString(stdout, "OK\n"); err != nil { + return 1 + } + if _, err := io.Copy(stdout, rc); err != nil { + // The header (and some bytes) already went out; the parent is streaming + // to the browser and can only observe the non-zero exit. + return 1 + } + return 0 +} + +// sanitizeHeader strips tabs/newlines so an error message can't break the +// tab-delimited single-line ERR header framing. +func sanitizeHeader(s string) string { + return strings.NewReplacer("\t", " ", "\n", " ", "\r", " ").Replace(s) +} diff --git a/internal/hostagent/fileops/fileops.go b/internal/hostagent/fileops/fileops.go new file mode 100644 index 00000000..3350accd --- /dev/null +++ b/internal/hostagent/fileops/fileops.go @@ -0,0 +1,246 @@ +// Package fileops implements the pure filesystem primitives behind the +// in-dashboard file manager (FILES.md): directory listing, mkdir, move, copy, +// delete, and streamed open/save, plus lexical path containment. +// +// It is deliberately identity-agnostic: every function operates on an +// already-resolved absolute path and carries no notion of "which user" or any +// privilege logic. Two consumers run these exact primitives — the fake +// host-agent (hostagent.FakeFileManager, in-process as the dev operator) and +// the real host-agent's __fileworker child (re-exec'd as the requesting user's +// UID, internal/hostagent/filemgr). Keeping the primitives here means the same +// tested code path runs in both, and the privilege-drop plumbing stays a thin +// fork-and-frame shell around it. +// +// Errors are the plain os/fs errors (fs.ErrNotExist, fs.ErrExist, +// fs.ErrPermission, syscall.ENOSPC) so callers map them to wire codes with +// errors.Is without a bespoke error taxonomy. +package fileops + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/malmoos/malmo/internal/protocol" +) + +// ErrInvalidPath is returned by Resolve when a relative path escapes its root +// (is absolute, contains a ".." that climbs above the base, or holds a NUL). +// It denotes a malformed request, not a missing file — callers map it to 400. +var ErrInvalidPath = errors.New("invalid path") + +// ErrIsDir is returned when a content transfer (download/upload) targets a +// directory. Callers map it to a validation error, not a not-found. +var ErrIsDir = errors.New("is a directory") + +// Resolve joins a cleaned relative path onto an absolute base and returns the +// absolute target, rejecting anything that would escape the base. base must be +// an absolute, already-trusted root (the user's home or the shared tree); rel +// is the untrusted path from the request. This is the lexical containment +// layer; the kernel-enforced UID check in the real agent is the real backstop +// (FILES.md # Authorization). +func Resolve(base, rel string) (string, error) { + base = filepath.Clean(base) + if rel == "" || rel == "." { + return base, nil + } + if strings.ContainsRune(rel, 0) || filepath.IsAbs(rel) { + return "", ErrInvalidPath + } + abs := filepath.Join(base, rel) + if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) { + return "", ErrInvalidPath + } + return abs, nil +} + +// List returns the directory entries at abs. Dotfiles are included with +// Hidden=true (the UI filters them by default); directories report SizeBytes 0. +// Entries that vanish or become unreadable between the readdir and the stat are +// skipped rather than failing the whole listing. +func List(abs string) ([]protocol.FileEntry, error) { + ents, err := os.ReadDir(abs) + if err != nil { + return nil, err + } + out := make([]protocol.FileEntry, 0, len(ents)) + for _, e := range ents { + info, err := e.Info() + if err != nil { + continue + } + size := int64(0) + if !e.IsDir() { + size = info.Size() + } + out = append(out, protocol.FileEntry{ + Name: e.Name(), + Dir: e.IsDir(), + SizeBytes: size, + Mtime: info.ModTime().UTC().Format(time.RFC3339), + Hidden: strings.HasPrefix(e.Name(), "."), + }) + } + return out, nil +} + +// Mkdir creates a single directory. It is Mkdir, not MkdirAll — the parent must +// already exist, so a client cannot materialize a chain of intermediate dirs it +// did not mean to. Returns fs.ErrExist if the name is taken. +func Mkdir(abs string) error { + return os.Mkdir(abs, 0o755) +} + +// Delete permanently removes a file or directory tree (no trash in v1). Missing +// targets return fs.ErrNotExist so the caller can 404 rather than silently +// succeeding. Uses Lstat so a symlink is removed as the link, not its target. +func Delete(abs string) error { + if _, err := os.Lstat(abs); err != nil { + return err + } + return os.RemoveAll(abs) +} + +// Move renames fromAbs to toAbs, refusing to clobber an existing destination. +// Falls back to copy-then-delete across filesystems (home and shared can sit on +// different mounts), so a home → shared move works even when os.Rename can't. +func Move(fromAbs, toAbs string) error { + if err := checkNotExist(toAbs); err != nil { + return err + } + if err := os.Rename(fromAbs, toAbs); err != nil { + if errors.Is(err, syscall.EXDEV) { + if cerr := Copy(fromAbs, toAbs); cerr != nil { + return cerr + } + return os.RemoveAll(fromAbs) + } + return err + } + return nil +} + +// Copy duplicates fromAbs to toAbs (a file, or a directory tree recursively), +// refusing to clobber an existing destination. Symlinks are copied as symlinks. +func Copy(fromAbs, toAbs string) error { + info, err := os.Lstat(fromAbs) + if err != nil { + return err + } + if err := checkNotExist(toAbs); err != nil { + return err + } + switch { + case info.IsDir(): + return copyTree(fromAbs, toAbs) + case info.Mode()&fs.ModeSymlink != 0: + return copySymlink(fromAbs, toAbs) + default: + return copyFile(fromAbs, toAbs, info.Mode()) + } +} + +// Open returns a reader over the file at abs for a streamed download. The caller +// closes it. A directory target is rejected with ErrIsDir. +func Open(abs string) (io.ReadCloser, error) { + info, err := os.Stat(abs) + if err != nil { + return nil, err + } + if info.IsDir() { + return nil, fmt.Errorf("%s: %w", abs, ErrIsDir) + } + return os.Open(abs) +} + +// Save writes r to the file at abs for a streamed upload, replacing any existing +// file (O_TRUNC — v1 has no resumable upload, so an interrupted transfer +// restarts). A short write from a full disk surfaces as syscall.ENOSPC. +func Save(abs string, r io.Reader) error { + out, err := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(out, r); err != nil { + out.Close() + return err + } + return out.Close() +} + +// checkNotExist returns fs.ErrExist if abs already exists, nil if it does not, +// or the underlying error otherwise. Used to make move/copy non-clobbering. +func checkNotExist(abs string) error { + _, err := os.Lstat(abs) + if err == nil { + return fmt.Errorf("%s: %w", abs, fs.ErrExist) + } + if !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} + +func copyFile(src, dst string, mode fs.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} + +func copySymlink(src, dst string) error { + target, err := os.Readlink(src) + if err != nil { + return err + } + return os.Symlink(target, dst) +} + +func copyTree(src, dst string) error { + if err := os.Mkdir(dst, 0o755); err != nil { + return err + } + ents, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range ents { + s := filepath.Join(src, e.Name()) + d := filepath.Join(dst, e.Name()) + info, err := e.Info() + if err != nil { + return err + } + switch { + case e.IsDir(): + if err := copyTree(s, d); err != nil { + return err + } + case info.Mode()&fs.ModeSymlink != 0: + if err := copySymlink(s, d); err != nil { + return err + } + default: + if err := copyFile(s, d, info.Mode()); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/hostagent/fileops/fileops_test.go b/internal/hostagent/fileops/fileops_test.go new file mode 100644 index 00000000..c002ad97 --- /dev/null +++ b/internal/hostagent/fileops/fileops_test.go @@ -0,0 +1,432 @@ +package fileops + +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolve(t *testing.T) { + base := t.TempDir() + cases := []struct { + name string + rel string + want string + wantErr bool + }{ + {"empty is base", "", base, false}, + {"dot is base", ".", base, false}, + {"simple child", "Photos", filepath.Join(base, "Photos"), false}, + {"nested child", "Photos/2024/img.jpg", filepath.Join(base, "Photos/2024/img.jpg"), false}, + {"interior dotdot stays inside", "a/../b", filepath.Join(base, "b"), false}, + {"escape via dotdot", "../secret", "", true}, + {"escape via nested dotdot", "a/../../secret", "", true}, + {"absolute rejected", "/etc/passwd", "", true}, + {"nul rejected", "a\x00b", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Resolve(base, tc.rel) + if tc.wantErr { + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("want ErrInvalidPath, got %v (path %q)", err, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestList(t *testing.T) { + base := t.TempDir() + writeFile(t, filepath.Join(base, "note.txt"), "hello") + writeFile(t, filepath.Join(base, ".hidden"), "x") + if err := os.Mkdir(filepath.Join(base, "Photos"), 0o755); err != nil { + t.Fatal(err) + } + + entries, err := List(base) + if err != nil { + t.Fatalf("List: %v", err) + } + byName := map[string]struct { + dir bool + size int64 + hidden bool + }{} + for _, e := range entries { + byName[e.Name] = struct { + dir bool + size int64 + hidden bool + }{e.Dir, e.SizeBytes, e.Hidden} + if e.Mtime == "" { + t.Errorf("entry %q has empty mtime", e.Name) + } + } + if got := byName["note.txt"]; got.dir || got.size != 5 || got.hidden { + t.Errorf("note.txt: got %+v", got) + } + if got := byName[".hidden"]; !got.hidden { + t.Errorf(".hidden: expected hidden=true, got %+v", got) + } + if got := byName["Photos"]; !got.dir || got.size != 0 { + t.Errorf("Photos: expected dir with size 0, got %+v", got) + } +} + +func TestListNotFound(t *testing.T) { + _, err := List(filepath.Join(t.TempDir(), "nope")) + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +func TestMkdir(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "New") + if err := Mkdir(dir); err != nil { + t.Fatalf("Mkdir: %v", err) + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Fatalf("dir not created: %v", err) + } + if err := Mkdir(dir); !errors.Is(err, fs.ErrExist) { + t.Fatalf("re-mkdir: want ErrExist, got %v", err) + } + if err := Mkdir(filepath.Join(base, "missing", "child")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("mkdir under missing parent: want ErrNotExist, got %v", err) + } +} + +func TestDelete(t *testing.T) { + base := t.TempDir() + f := filepath.Join(base, "gone.txt") + writeFile(t, f, "x") + if err := Delete(f); err != nil { + t.Fatalf("Delete file: %v", err) + } + if _, err := os.Lstat(f); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("file still present: %v", err) + } + + tree := filepath.Join(base, "tree") + mustMkdir(t, tree) + writeFile(t, filepath.Join(tree, "a.txt"), "a") + if err := Delete(tree); err != nil { + t.Fatalf("Delete tree: %v", err) + } + + if err := Delete(filepath.Join(base, "nope")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("delete missing: want ErrNotExist, got %v", err) + } +} + +func TestMove(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "a.txt") + dst := filepath.Join(base, "b.txt") + writeFile(t, src, "data") + if err := Move(src, dst); err != nil { + t.Fatalf("Move: %v", err) + } + if got := readFile(t, dst); got != "data" { + t.Fatalf("moved content = %q", got) + } + if _, err := os.Lstat(src); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("source still present: %v", err) + } +} + +func TestMoveRefusesClobber(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "a.txt") + dst := filepath.Join(base, "b.txt") + writeFile(t, src, "one") + writeFile(t, dst, "two") + if err := Move(src, dst); !errors.Is(err, fs.ErrExist) { + t.Fatalf("want ErrExist, got %v", err) + } + if got := readFile(t, dst); got != "two" { + t.Fatalf("destination clobbered: %q", got) + } +} + +func TestMoveMissingSource(t *testing.T) { + base := t.TempDir() + err := Move(filepath.Join(base, "nope"), filepath.Join(base, "dst")) + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +func TestCopyFile(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "a.txt") + dst := filepath.Join(base, "b.txt") + writeFile(t, src, "payload") + if err := Copy(src, dst); err != nil { + t.Fatalf("Copy: %v", err) + } + if got := readFile(t, dst); got != "payload" { + t.Fatalf("copied content = %q", got) + } + if got := readFile(t, src); got != "payload" { + t.Fatalf("source altered: %q", got) + } +} + +func TestCopyTree(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "src") + mustMkdir(t, src) + mustMkdir(t, filepath.Join(src, "sub")) + writeFile(t, filepath.Join(src, "top.txt"), "top") + writeFile(t, filepath.Join(src, "sub", "deep.txt"), "deep") + + dst := filepath.Join(base, "dst") + if err := Copy(src, dst); err != nil { + t.Fatalf("Copy tree: %v", err) + } + if got := readFile(t, filepath.Join(dst, "top.txt")); got != "top" { + t.Fatalf("top.txt = %q", got) + } + if got := readFile(t, filepath.Join(dst, "sub", "deep.txt")); got != "deep" { + t.Fatalf("sub/deep.txt = %q", got) + } +} + +func TestCopySymlink(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "target.txt") + writeFile(t, target, "t") + link := filepath.Join(base, "link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + dst := filepath.Join(base, "link-copy") + if err := Copy(link, dst); err != nil { + t.Fatalf("Copy symlink: %v", err) + } + got, err := os.Readlink(dst) + if err != nil { + t.Fatalf("Readlink: %v", err) + } + if got != target { + t.Fatalf("symlink target = %q, want %q", got, target) + } +} + +func TestCopyRefusesClobber(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "a.txt") + dst := filepath.Join(base, "b.txt") + writeFile(t, src, "one") + writeFile(t, dst, "two") + if err := Copy(src, dst); !errors.Is(err, fs.ErrExist) { + t.Fatalf("want ErrExist, got %v", err) + } +} + +func TestCopyFileDestParentMissing(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "a.txt") + writeFile(t, src, "x") + // Parent "missing/" does not exist, so creating the destination file fails. + err := Copy(src, filepath.Join(base, "missing", "b.txt")) + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +func TestCopyTreeDestParentMissing(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "src") + mustMkdir(t, src) + writeFile(t, filepath.Join(src, "f.txt"), "x") + err := Copy(src, filepath.Join(base, "missing", "dst")) + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +func TestCopyDestParentIsFile(t *testing.T) { + base := t.TempDir() + src := filepath.Join(base, "a.txt") + writeFile(t, src, "x") + notDir := filepath.Join(base, "afile") + writeFile(t, notDir, "y") + // Lstat of "/child" yields ENOTDIR — a non-ErrNotExist error that the + // clobber check must surface rather than treat as "destination is free". + err := Copy(src, filepath.Join(notDir, "child")) + if err == nil || errors.Is(err, fs.ErrExist) { + t.Fatalf("want a non-clobber error, got %v", err) + } +} + +func TestCopyUnreadableFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permissions") + } + base := t.TempDir() + src := filepath.Join(base, "secret.txt") + writeFile(t, src, "x") + if err := os.Chmod(src, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(src, 0o644) }) + if err := Copy(src, filepath.Join(base, "copy.txt")); !errors.Is(err, fs.ErrPermission) { + t.Fatalf("want ErrPermission, got %v", err) + } +} + +func TestCopyTreeWithUnreadableChild(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permissions") + } + base := t.TempDir() + src := filepath.Join(base, "src") + mustMkdir(t, src) + child := filepath.Join(src, "secret.txt") + writeFile(t, child, "x") + if err := os.Chmod(child, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(child, 0o644) }) + if err := Copy(src, filepath.Join(base, "dst")); !errors.Is(err, fs.ErrPermission) { + t.Fatalf("want ErrPermission, got %v", err) + } +} + +func TestCopyTreeNestedUnreadable(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permissions") + } + base := t.TempDir() + src := filepath.Join(base, "src") + sub := filepath.Join(src, "sub") + mustMkdir(t, src) + mustMkdir(t, sub) + secret := filepath.Join(sub, "secret.txt") + writeFile(t, secret, "x") + if err := os.Chmod(secret, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(secret, 0o644) }) + // The error must propagate up through the recursive copyTree of "sub". + if err := Copy(src, filepath.Join(base, "dst")); !errors.Is(err, fs.ErrPermission) { + t.Fatalf("want ErrPermission, got %v", err) + } +} + +func TestOpen(t *testing.T) { + base := t.TempDir() + f := filepath.Join(base, "movie.bin") + writeFile(t, f, "bytes") + rc, err := Open(f) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer rc.Close() + got, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(got) != "bytes" { + t.Fatalf("content = %q", got) + } +} + +func TestOpenRejectsDir(t *testing.T) { + base := t.TempDir() + if _, err := Open(base); !errors.Is(err, ErrIsDir) { + t.Fatalf("want ErrIsDir, got %v", err) + } +} + +func TestOpenNotFound(t *testing.T) { + if _, err := Open(filepath.Join(t.TempDir(), "nope")); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +func TestSave(t *testing.T) { + base := t.TempDir() + f := filepath.Join(base, "up.txt") + if err := Save(f, strings.NewReader("first")); err != nil { + t.Fatalf("Save: %v", err) + } + if got := readFile(t, f); got != "first" { + t.Fatalf("content = %q", got) + } + // O_TRUNC: a second Save fully replaces the file, no leftover tail. + if err := Save(f, strings.NewReader("hi")); err != nil { + t.Fatalf("Save overwrite: %v", err) + } + if got := readFile(t, f); got != "hi" { + t.Fatalf("overwritten content = %q", got) + } +} + +func TestSaveIntoMissingDir(t *testing.T) { + err := Save(filepath.Join(t.TempDir(), "missing", "x.txt"), strings.NewReader("x")) + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +func TestSaveReaderError(t *testing.T) { + sentinel := errors.New("boom") + err := Save(filepath.Join(t.TempDir(), "up.txt"), errReader{sentinel}) + if !errors.Is(err, sentinel) { + t.Fatalf("want sentinel error, got %v", err) + } +} + +func TestCopyMissingSource(t *testing.T) { + base := t.TempDir() + err := Copy(filepath.Join(base, "nope"), filepath.Join(base, "dst")) + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("want ErrNotExist, got %v", err) + } +} + +// --- helpers --- + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func mustMkdir(t *testing.T, path string) { + t.Helper() + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } +} + +// errReader fails on the first Read, simulating a transfer that breaks partway. +type errReader struct{ err error } + +func (e errReader) Read([]byte) (int, error) { return 0, e.err } diff --git a/internal/hostagent/files.go b/internal/hostagent/files.go new file mode 100644 index 00000000..ec551ac9 --- /dev/null +++ b/internal/hostagent/files.go @@ -0,0 +1,200 @@ +package hostagent + +import ( + "errors" + "io" + "io/fs" + "log/slog" + "net/http" + "syscall" + + "github.com/malmoos/malmo/internal/hostagent/fileops" + "github.com/malmoos/malmo/internal/protocol" +) + +// FileManager is the consumer-side seam for the in-dashboard file manager's +// filesystem work (FILES.md, /v1/files/*). Every method acts on behalf of user +// under a logical root — "home" (the user's /home//) or "shared" +// (/srv/malmo/shared/); the implementation resolves the root to an absolute +// base, re-validates path containment, and runs the op. The fake +// (FakeFileManager) runs it in-process as the dev operator; the real one +// (filemgr.LinuxFileManager) runs it in a child re-exec'd as the user's UID/GID +// so POSIX 0750/02770 is the kernel-enforced backstop and created files get +// correct ownership natively. +// +// Errors carry the standard os/fs sentinels (fs.ErrNotExist / fs.ErrExist / +// fs.ErrPermission, syscall.ENOSPC) plus fileops.ErrInvalidPath / ErrIsDir, so +// writeFileErr maps them to wire codes with errors.Is and no bespoke taxonomy. +type FileManager interface { + List(user, root, path string) ([]protocol.FileEntry, error) + Mkdir(user, root, path string) error + Delete(user, root, path string) error + Move(user string, from, to protocol.FileLocation) error + Copy(user string, from, to protocol.FileLocation) error + // Open returns a reader over the file for a streamed download; the caller + // closes it. Any error (not-found, permission, is-a-directory) surfaces here, + // before bytes flow, so the handler can still set a proper status. + Open(user, root, path string) (io.ReadCloser, error) + // Save writes body to the file for a streamed upload, replacing any existing + // file. It reads body to completion (or until an error) without buffering. + Save(user, root, path string, body io.Reader) error +} + +func validRoot(root string) bool { return root == "home" || root == "shared" } + +func (a *Agent) filesList(w http.ResponseWriter, r *http.Request) { + var req protocol.FilesPathRequest + if !decode(w, r, &req) { + return + } + if !a.filesGuard(w, req.User, req.Root) { + return + } + entries, err := a.Files.List(req.User, req.Root, req.Path) + if err != nil { + writeFileErr(w, "files.list", err) + return + } + writeJSON(w, http.StatusOK, protocol.FilesListResponse{Entries: entries}) +} + +func (a *Agent) filesMkdir(w http.ResponseWriter, r *http.Request) { + var req protocol.FilesPathRequest + if !decode(w, r, &req) { + return + } + if !a.filesGuard(w, req.User, req.Root) { + return + } + if err := a.Files.Mkdir(req.User, req.Root, req.Path); err != nil { + writeFileErr(w, "files.mkdir", err) + return + } + writeJSON(w, http.StatusOK, struct{}{}) +} + +func (a *Agent) filesDelete(w http.ResponseWriter, r *http.Request) { + var req protocol.FilesPathRequest + if !decode(w, r, &req) { + return + } + if !a.filesGuard(w, req.User, req.Root) { + return + } + if err := a.Files.Delete(req.User, req.Root, req.Path); err != nil { + writeFileErr(w, "files.delete", err) + return + } + writeJSON(w, http.StatusOK, struct{}{}) +} + +func (a *Agent) filesMove(w http.ResponseWriter, r *http.Request) { + a.filesTransfer(w, r, "files.move", func(user string, from, to protocol.FileLocation) error { + return a.Files.Move(user, from, to) + }) +} + +func (a *Agent) filesCopy(w http.ResponseWriter, r *http.Request) { + a.filesTransfer(w, r, "files.copy", func(user string, from, to protocol.FileLocation) error { + return a.Files.Copy(user, from, to) + }) +} + +func (a *Agent) filesTransfer(w http.ResponseWriter, r *http.Request, op string, do func(user string, from, to protocol.FileLocation) error) { + var req protocol.FilesTransferRequest + if !decode(w, r, &req) { + return + } + if a.Files == nil { + writeErr(w, http.StatusNotImplemented, "not-implemented", "file manager not available") + return + } + if req.User == "" { + writeErr(w, http.StatusBadRequest, "bad-request", "user is required") + return + } + if !validRoot(req.From.Root) || !validRoot(req.To.Root) { + writeErr(w, http.StatusBadRequest, "bad-request", "root must be home or shared") + return + } + if err := do(req.User, req.From, req.To); err != nil { + writeFileErr(w, op, err) + return + } + writeJSON(w, http.StatusOK, struct{}{}) +} + +func (a *Agent) filesDownload(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + user, root, path := q.Get("user"), q.Get("root"), q.Get("path") + if !a.filesGuard(w, user, root) { + return + } + rc, err := a.Files.Open(user, root, path) + if err != nil { + writeFileErr(w, "files.download", err) + return + } + defer rc.Close() + w.Header().Set("Content-Type", "application/octet-stream") + if _, err := io.Copy(w, rc); err != nil { + // Status is already 200 (bytes have flowed); a mid-stream error is a + // client disconnect or a read fault we can only log. + slog.Warn("file download interrupted", "user", user, "root", root, "err", err) + } +} + +func (a *Agent) filesUpload(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + user, root, path := q.Get("user"), q.Get("root"), q.Get("path") + if !a.filesGuard(w, user, root) { + return + } + if err := a.Files.Save(user, root, path, r.Body); err != nil { + writeFileErr(w, "files.upload", err) + return + } + writeJSON(w, http.StatusOK, struct{}{}) +} + +// filesGuard validates the seam is wired and the (user, root) are well-formed, +// writing the appropriate error and returning false when not. Shared by every +// single-location /v1/files/* handler. +func (a *Agent) filesGuard(w http.ResponseWriter, user, root string) bool { + if a.Files == nil { + writeErr(w, http.StatusNotImplemented, "not-implemented", "file manager not available") + return false + } + if user == "" { + writeErr(w, http.StatusBadRequest, "bad-request", "user is required") + return false + } + if !validRoot(root) { + writeErr(w, http.StatusBadRequest, "bad-request", "root must be home or shared") + return false + } + return true +} + +// writeFileErr maps an error from a FileManager op to the wire code + HTTP +// status the brain expects (BRAIN_HOST_PROTOCOL.md # Files endpoints). Anything +// unrecognized is a 500 — a real host fault the brain surfaces as a 502. +func writeFileErr(w http.ResponseWriter, op string, err error) { + switch { + case errors.Is(err, fileops.ErrInvalidPath): + writeErr(w, http.StatusBadRequest, "invalid-path", "path escapes its root") + case errors.Is(err, fileops.ErrIsDir): + writeErr(w, http.StatusUnprocessableEntity, "is-a-directory", "path is a directory") + case errors.Is(err, fs.ErrNotExist): + writeErr(w, http.StatusNotFound, "not-found", "no such file or directory") + case errors.Is(err, fs.ErrExist): + writeErr(w, http.StatusConflict, "exists", "destination already exists") + case errors.Is(err, fs.ErrPermission): + writeErr(w, http.StatusForbidden, "permission-denied", "permission denied") + case errors.Is(err, syscall.ENOSPC): + writeErr(w, http.StatusInsufficientStorage, "no-space", "no space left on device") + default: + slog.Error("file op failed", "step", op, "err", err) + writeErr(w, http.StatusInternalServerError, "file-op-failed", "file operation failed") + } +} diff --git a/internal/hostagent/files_test.go b/internal/hostagent/files_test.go new file mode 100644 index 00000000..3c0bc06a --- /dev/null +++ b/internal/hostagent/files_test.go @@ -0,0 +1,274 @@ +package hostagent + +import ( + "bytes" + "errors" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/malmoos/malmo/internal/hostagent/fileops" + "github.com/malmoos/malmo/internal/protocol" +) + +func newFilesAgent(t *testing.T) (*http.ServeMux, string, string) { + t.Helper() + home := t.TempDir() + shared := t.TempDir() + a, mux := newTestAgent(&stubVerifier{}) + a.Files = NewFakeFileManager(home, shared) + return mux, home, shared +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// contentReq builds a raw GET/PUT to /v1/files/content with query params and an +// optional body (the streaming endpoints don't take a JSON body). +func contentReq(t *testing.T, mux *http.ServeMux, method, user, root, path string, body io.Reader) *httptest.ResponseRecorder { + t.Helper() + q := url.Values{"user": {user}, "root": {root}, "path": {path}} + req := httptest.NewRequest(method, "/v1/files/content?"+q.Encode(), body) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w +} + +func TestFilesListReturnsEntries(t *testing.T) { + mux, home, _ := newFilesAgent(t) + writeFile(t, filepath.Join(home, "note.txt"), "hi") + if err := os.Mkdir(filepath.Join(home, "Photos"), 0o755); err != nil { + t.Fatal(err) + } + + w := post(t, mux, "/v1/files/list", protocol.FilesPathRequest{User: "alex", Root: "home", Path: ""}) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (%s)", w.Code, w.Body) + } + resp := decodeBody[protocol.FilesListResponse](t, w) + names := map[string]bool{} + for _, e := range resp.Entries { + names[e.Name] = true + } + if !names["note.txt"] || !names["Photos"] { + t.Fatalf("missing entries: %+v", resp.Entries) + } +} + +func TestFilesListInvalidRoot(t *testing.T) { + mux, _, _ := newFilesAgent(t) + w := post(t, mux, "/v1/files/list", protocol.FilesPathRequest{User: "alex", Root: "app-state"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", w.Code) + } +} + +func TestFilesListMissingUser(t *testing.T) { + mux, _, _ := newFilesAgent(t) + w := post(t, mux, "/v1/files/list", protocol.FilesPathRequest{Root: "home"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", w.Code) + } +} + +func TestFilesListNotWired(t *testing.T) { + _, mux := newTestAgent(&stubVerifier{}) // Files left nil + w := post(t, mux, "/v1/files/list", protocol.FilesPathRequest{User: "alex", Root: "home"}) + if w.Code != http.StatusNotImplemented { + t.Fatalf("want 501, got %d", w.Code) + } +} + +func TestFilesPathTraversalRejected(t *testing.T) { + mux, _, _ := newFilesAgent(t) + w := post(t, mux, "/v1/files/list", protocol.FilesPathRequest{User: "alex", Root: "home", Path: "../../etc"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", w.Code) + } + if code := decodeBody[protocol.Error](t, w).Code; code != "invalid-path" { + t.Fatalf("want invalid-path, got %q", code) + } +} + +func TestFilesMkdir(t *testing.T) { + mux, home, _ := newFilesAgent(t) + w := post(t, mux, "/v1/files/mkdir", protocol.FilesPathRequest{User: "alex", Root: "home", Path: "New"}) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (%s)", w.Code, w.Body) + } + if info, err := os.Stat(filepath.Join(home, "New")); err != nil || !info.IsDir() { + t.Fatalf("dir not created: %v", err) + } +} + +func TestFilesMkdirExists(t *testing.T) { + mux, home, _ := newFilesAgent(t) + if err := os.Mkdir(filepath.Join(home, "New"), 0o755); err != nil { + t.Fatal(err) + } + w := post(t, mux, "/v1/files/mkdir", protocol.FilesPathRequest{User: "alex", Root: "home", Path: "New"}) + if w.Code != http.StatusConflict { + t.Fatalf("want 409, got %d", w.Code) + } + if code := decodeBody[protocol.Error](t, w).Code; code != "exists" { + t.Fatalf("want exists, got %q", code) + } +} + +func TestFilesDeleteNotFound(t *testing.T) { + mux, _, _ := newFilesAgent(t) + w := post(t, mux, "/v1/files/delete", protocol.FilesPathRequest{User: "alex", Root: "home", Path: "gone.txt"}) + if w.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", w.Code) + } +} + +func TestFilesDelete(t *testing.T) { + mux, home, _ := newFilesAgent(t) + writeFile(t, filepath.Join(home, "gone.txt"), "x") + w := post(t, mux, "/v1/files/delete", protocol.FilesPathRequest{User: "alex", Root: "home", Path: "gone.txt"}) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + if _, err := os.Stat(filepath.Join(home, "gone.txt")); !os.IsNotExist(err) { + t.Fatalf("file still present: %v", err) + } +} + +func TestFilesMoveAcrossRoots(t *testing.T) { + mux, home, shared := newFilesAgent(t) + writeFile(t, filepath.Join(home, "a.txt"), "payload") + w := post(t, mux, "/v1/files/move", protocol.FilesTransferRequest{ + User: "alex", + From: protocol.FileLocation{Root: "home", Path: "a.txt"}, + To: protocol.FileLocation{Root: "shared", Path: "a.txt"}, + }) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (%s)", w.Code, w.Body) + } + if got, err := os.ReadFile(filepath.Join(shared, "a.txt")); err != nil || string(got) != "payload" { + t.Fatalf("moved file wrong: %q err=%v", got, err) + } + if _, err := os.Stat(filepath.Join(home, "a.txt")); !os.IsNotExist(err) { + t.Fatalf("source still present: %v", err) + } +} + +func TestFilesCopyClobber(t *testing.T) { + mux, home, _ := newFilesAgent(t) + writeFile(t, filepath.Join(home, "a.txt"), "one") + writeFile(t, filepath.Join(home, "b.txt"), "two") + w := post(t, mux, "/v1/files/copy", protocol.FilesTransferRequest{ + User: "alex", + From: protocol.FileLocation{Root: "home", Path: "a.txt"}, + To: protocol.FileLocation{Root: "home", Path: "b.txt"}, + }) + if w.Code != http.StatusConflict { + t.Fatalf("want 409, got %d", w.Code) + } +} + +func TestFilesTransferInvalidRoot(t *testing.T) { + mux, _, _ := newFilesAgent(t) + w := post(t, mux, "/v1/files/move", protocol.FilesTransferRequest{ + User: "alex", + From: protocol.FileLocation{Root: "home", Path: "a"}, + To: protocol.FileLocation{Root: "elsewhere", Path: "a"}, + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", w.Code) + } +} + +func TestFilesDownload(t *testing.T) { + mux, home, _ := newFilesAgent(t) + writeFile(t, filepath.Join(home, "movie.bin"), "the-bytes") + w := contentReq(t, mux, http.MethodGet, "alex", "home", "movie.bin", nil) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (%s)", w.Code, w.Body) + } + if ct := w.Header().Get("Content-Type"); ct != "application/octet-stream" { + t.Fatalf("content-type = %q", ct) + } + if w.Body.String() != "the-bytes" { + t.Fatalf("body = %q", w.Body.String()) + } +} + +func TestFilesDownloadNotFound(t *testing.T) { + mux, _, _ := newFilesAgent(t) + w := contentReq(t, mux, http.MethodGet, "alex", "home", "nope.bin", nil) + if w.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", w.Code) + } +} + +func TestFilesDownloadRejectsDir(t *testing.T) { + mux, home, _ := newFilesAgent(t) + if err := os.Mkdir(filepath.Join(home, "Photos"), 0o755); err != nil { + t.Fatal(err) + } + w := contentReq(t, mux, http.MethodGet, "alex", "home", "Photos", nil) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("want 422, got %d", w.Code) + } +} + +func TestFilesUploadThenDownload(t *testing.T) { + mux, home, _ := newFilesAgent(t) + w := contentReq(t, mux, http.MethodPut, "alex", "home", "up.txt", bytes.NewReader([]byte("uploaded"))) + if w.Code != http.StatusOK { + t.Fatalf("upload: want 200, got %d (%s)", w.Code, w.Body) + } + if got, err := os.ReadFile(filepath.Join(home, "up.txt")); err != nil || string(got) != "uploaded" { + t.Fatalf("uploaded file wrong: %q err=%v", got, err) + } + dl := contentReq(t, mux, http.MethodGet, "alex", "home", "up.txt", nil) + if dl.Body.String() != "uploaded" { + t.Fatalf("roundtrip body = %q", dl.Body.String()) + } +} + +func TestFilesUploadInvalidRoot(t *testing.T) { + mux, _, _ := newFilesAgent(t) + w := contentReq(t, mux, http.MethodPut, "alex", "bogus", "up.txt", bytes.NewReader([]byte("x"))) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", w.Code) + } +} + +func TestWriteFileErrMapping(t *testing.T) { + cases := []struct { + err error + status int + code string + }{ + {fileops.ErrInvalidPath, http.StatusBadRequest, "invalid-path"}, + {fileops.ErrIsDir, http.StatusUnprocessableEntity, "is-a-directory"}, + {fs.ErrNotExist, http.StatusNotFound, "not-found"}, + {fs.ErrExist, http.StatusConflict, "exists"}, + {fs.ErrPermission, http.StatusForbidden, "permission-denied"}, + {syscall.ENOSPC, http.StatusInsufficientStorage, "no-space"}, + {errors.New("boom"), http.StatusInternalServerError, "file-op-failed"}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + writeFileErr(w, "test", tc.err) + if w.Code != tc.status { + t.Errorf("%v: status = %d, want %d", tc.err, w.Code, tc.status) + } + if code := decodeBody[protocol.Error](t, w).Code; code != tc.code { + t.Errorf("%v: code = %q, want %q", tc.err, code, tc.code) + } + } +} diff --git a/internal/hostclient/files.go b/internal/hostclient/files.go new file mode 100644 index 00000000..9bfb8b35 --- /dev/null +++ b/internal/hostclient/files.go @@ -0,0 +1,138 @@ +package hostclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/malmoos/malmo/internal/protocol" +) + +// FileOpError carries the host-agent's error code, message, and HTTP status from +// a /v1/files/* call so the brain can map it to the right dashboard response +// (not-found → 404, exists → 409, no-space → 507, permission-denied → 403, +// invalid-path → 400). The file methods bypass do — which flattens every error +// to an opaque string — to keep this discrimination, mirroring ResolveHome. +// Check with errors.As. +type FileOpError struct { + Code string + Message string + Status int +} + +func (e *FileOpError) Error() string { + return fmt.Sprintf("host-agent /v1/files: %s (%s, status %d)", e.Message, e.Code, e.Status) +} + +// fileOpError builds a *FileOpError from a non-2xx host-agent response, reading +// the standard {code, message} body. A body that fails to decode still yields a +// usable error keyed on the HTTP status. +func fileOpError(resp *http.Response) *FileOpError { + var e protocol.Error + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Code == "" { + e.Code = "host-agent-error" + e.Message = resp.Status + } + return &FileOpError{Code: e.Code, Message: e.Message, Status: resp.StatusCode} +} + +// FilesList returns the directory listing at (root, path) for user. +func (c *Client) FilesList(ctx context.Context, user, root, path string) (protocol.FilesListResponse, error) { + var out protocol.FilesListResponse + err := c.filesDo(ctx, "/v1/files/list", protocol.FilesPathRequest{User: user, Root: root, Path: path}, &out) + return out, err +} + +// FilesMkdir creates a directory at (root, path) for user. +func (c *Client) FilesMkdir(ctx context.Context, user, root, path string) error { + return c.filesDo(ctx, "/v1/files/mkdir", protocol.FilesPathRequest{User: user, Root: root, Path: path}, nil) +} + +// FilesDelete permanently removes the file or tree at (root, path) for user. +func (c *Client) FilesDelete(ctx context.Context, user, root, path string) error { + return c.filesDo(ctx, "/v1/files/delete", protocol.FilesPathRequest{User: user, Root: root, Path: path}, nil) +} + +// FilesMove renames/moves from → to (which may cross roots) for user. +func (c *Client) FilesMove(ctx context.Context, user string, from, to protocol.FileLocation) error { + return c.filesDo(ctx, "/v1/files/move", protocol.FilesTransferRequest{User: user, From: from, To: to}, nil) +} + +// FilesCopy copies from → to (which may cross roots) for user. +func (c *Client) FilesCopy(ctx context.Context, user string, from, to protocol.FileLocation) error { + return c.filesDo(ctx, "/v1/files/copy", protocol.FilesTransferRequest{User: user, From: from, To: to}, nil) +} + +// filesDo posts a metadata file op, decoding out on success and returning a +// *FileOpError (status-preserving) on any non-2xx. +func (c *Client) filesDo(ctx context.Context, path string, body, out any) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, "POST", "http://agent"+path, &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("host-agent unreachable: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fileOpError(resp) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +// FilesOpen streams a file download from host-agent. The returned ReadCloser is +// the raw octet-stream response body; the caller closes it. It uses the +// timeout-less stream client (a transfer can take minutes) and reports any +// pre-stream failure — not-found, permission, is-a-directory — as a +// *FileOpError before a single byte flows. +func (c *Client) FilesOpen(ctx context.Context, user, root, path string) (io.ReadCloser, error) { + q := url.Values{"user": {user}, "root": {root}, "path": {path}} + req, err := http.NewRequestWithContext(ctx, "GET", "http://agent/v1/files/content?"+q.Encode(), http.NoBody) + if err != nil { + return nil, err + } + resp, err := c.stream.Do(req) + if err != nil { + return nil, fmt.Errorf("host-agent unreachable: %w", err) + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + return nil, fileOpError(resp) + } + return resp.Body, nil +} + +// FilesSave streams an upload to host-agent, piping body straight through +// without buffering the whole file. It uses the timeout-less stream client and +// returns a *FileOpError on any non-2xx (e.g. no-space, permission-denied). +func (c *Client) FilesSave(ctx context.Context, user, root, path string, body io.Reader) error { + q := url.Values{"user": {user}, "root": {root}, "path": {path}} + req, err := http.NewRequestWithContext(ctx, "PUT", "http://agent/v1/files/content?"+q.Encode(), body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := c.stream.Do(req) + if err != nil { + return fmt.Errorf("host-agent unreachable: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fileOpError(resp) + } + return nil +} diff --git a/internal/hostclient/files_test.go b/internal/hostclient/files_test.go new file mode 100644 index 00000000..3895fad5 --- /dev/null +++ b/internal/hostclient/files_test.go @@ -0,0 +1,127 @@ +package hostclient + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/malmoos/malmo/internal/hostagent" + "github.com/malmoos/malmo/internal/protocol" +) + +// startFileAgent mounts a real hostagent.Agent backed by a FakeFileManager over +// temp dirs on a UNIX socket, so these tests exercise the actual /v1/files/* +// wire seam (client ↔ socket ↔ handlers ↔ fileops), including the FileOpError +// status/code round-trip. +func startFileAgent(t *testing.T) (*Client, string, string) { + t.Helper() + home := t.TempDir() + shared := t.TempDir() + sock := filepath.Join(t.TempDir(), "agent.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("listen: %v", err) + } + a := hostagent.New(nil, hostagent.NewFakePublisher("")) + a.Files = hostagent.NewFakeFileManager(home, shared) + mux := http.NewServeMux() + a.Mount(mux) + srv := &http.Server{Handler: mux} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + return New(sock), home, shared +} + +func TestFilesClientListMkdir(t *testing.T) { + c, home, _ := startFileAgent(t) + ctx := context.Background() + if err := c.FilesMkdir(ctx, "alex", "home", "Photos"); err != nil { + t.Fatalf("FilesMkdir: %v", err) + } + if info, err := os.Stat(filepath.Join(home, "Photos")); err != nil || !info.IsDir() { + t.Fatalf("dir not created: %v", err) + } + out, err := c.FilesList(ctx, "alex", "home", "") + if err != nil { + t.Fatalf("FilesList: %v", err) + } + if len(out.Entries) != 1 || out.Entries[0].Name != "Photos" { + t.Fatalf("entries = %+v", out.Entries) + } +} + +func TestFilesClientErrorMapping(t *testing.T) { + c, _, _ := startFileAgent(t) + err := c.FilesDelete(context.Background(), "alex", "home", "gone.txt") + var fe *FileOpError + if !errors.As(err, &fe) { + t.Fatalf("want *FileOpError, got %T: %v", err, err) + } + if fe.Status != http.StatusNotFound || fe.Code != "not-found" { + t.Fatalf("got status=%d code=%q", fe.Status, fe.Code) + } + if fe.Error() == "" { + t.Fatal("empty error string") + } +} + +func TestFilesClientMoveCopy(t *testing.T) { + c, home, shared := startFileAgent(t) + ctx := context.Background() + if err := os.WriteFile(filepath.Join(home, "a.txt"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + // copy home/a.txt → home/b.txt + if err := c.FilesCopy(ctx, "alex", + protocol.FileLocation{Root: "home", Path: "a.txt"}, + protocol.FileLocation{Root: "home", Path: "b.txt"}); err != nil { + t.Fatalf("FilesCopy: %v", err) + } + // move home/a.txt → shared/a.txt + if err := c.FilesMove(ctx, "alex", + protocol.FileLocation{Root: "home", Path: "a.txt"}, + protocol.FileLocation{Root: "shared", Path: "a.txt"}); err != nil { + t.Fatalf("FilesMove: %v", err) + } + if got, err := os.ReadFile(filepath.Join(shared, "a.txt")); err != nil || string(got) != "payload" { + t.Fatalf("moved file wrong: %q err=%v", got, err) + } + if got, err := os.ReadFile(filepath.Join(home, "b.txt")); err != nil || string(got) != "payload" { + t.Fatalf("copied file wrong: %q err=%v", got, err) + } +} + +func TestFilesClientUploadDownload(t *testing.T) { + c, home, _ := startFileAgent(t) + ctx := context.Background() + if err := c.FilesSave(ctx, "alex", "home", "up.txt", bytes.NewReader([]byte("streamed"))); err != nil { + t.Fatalf("FilesSave: %v", err) + } + if got, err := os.ReadFile(filepath.Join(home, "up.txt")); err != nil || string(got) != "streamed" { + t.Fatalf("saved file wrong: %q err=%v", got, err) + } + rc, err := c.FilesOpen(ctx, "alex", "home", "up.txt") + if err != nil { + t.Fatalf("FilesOpen: %v", err) + } + defer rc.Close() + got, _ := io.ReadAll(rc) + if string(got) != "streamed" { + t.Fatalf("downloaded = %q", got) + } +} + +func TestFilesClientOpenNotFound(t *testing.T) { + c, _, _ := startFileAgent(t) + _, err := c.FilesOpen(context.Background(), "alex", "home", "nope.bin") + var fe *FileOpError + if !errors.As(err, &fe) || fe.Status != http.StatusNotFound { + t.Fatalf("want 404 FileOpError, got %v", err) + } +} diff --git a/internal/protocol/host.go b/internal/protocol/host.go index 7269d95b..db8033f4 100644 --- a/internal/protocol/host.go +++ b/internal/protocol/host.go @@ -367,6 +367,56 @@ type JournalLine struct { Lost bool `json:"lost,omitempty"` } +// FileEntry is one directory entry returned by POST /v1/files/list (and the +// dashboard-facing /api/v1/files/list). Dir marks a directory; SizeBytes is the +// file size in bytes (0 for directories); Mtime is RFC3339; Hidden is true for +// dotfiles — a Finder-style convenience the UI toggles, not a security boundary +// (the UID drop is the boundary, FILES.md # Scope). See BRAIN_HOST_PROTOCOL.md +// # Files endpoints. +type FileEntry struct { + Name string `json:"name"` + Dir bool `json:"dir"` + SizeBytes int64 `json:"size_bytes"` + Mtime string `json:"mtime"` + Hidden bool `json:"hidden"` +} + +// FileLocation names a file or directory by logical root and a relative path +// within it. Root is "home" (the user's /home//) or "shared" +// (/srv/malmo/shared/); Path is relative to that root. It is the shape of the +// from/to objects on move/copy (FilesTransferRequest). host-agent resolves the +// root to an absolute base and re-validates path containment before acting. +type FileLocation struct { + Root string `json:"root"` + Path string `json:"path"` +} + +// FilesPathRequest is the shared body of POST /v1/files/{list,mkdir,delete}. +// User is the requesting account (host-agent drops to this user's UID for the +// op); Root is "home" | "shared"; Path is relative to the root. There is no +// "act as a different user" parameter — the brain always passes the session +// owner (FILES.md # Authorization). +type FilesPathRequest struct { + User string `json:"user"` + Root string `json:"root"` + Path string `json:"path"` +} + +// FilesListResponse is the 200 body of POST /v1/files/list. +type FilesListResponse struct { + Entries []FileEntry `json:"entries"` +} + +// FilesTransferRequest is the body of POST /v1/files/{move,copy}. A move or copy +// may cross roots (home → shared is a real cross-tree operation), which +// host-agent performs as the user's UID — so it only succeeds where the user +// has write access on both ends (the malmo-shared group grants the shared side). +type FilesTransferRequest struct { + User string `json:"user"` + From FileLocation `json:"from"` + To FileLocation `json:"to"` +} + // Error is the JSON error body shape on non-2xx responses. type Error struct { Code string `json:"code"` diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index cc0e236e..e4e166ab 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -103,6 +103,12 @@ export type SystemStorage = Schemas["SystemStorageDTO"]; export type DiskSpace = Schemas["DiskSpaceDTO"]; export type AppSecrets = Schemas["AppSecretsDTO"]; export type AppSecret = Schemas["AppSecretDTO"]; +// File manager (FILES.md). FileEntry is one directory entry; FileLocation names +// a file/dir by logical root ("home" | "shared") + relative path, the shape of +// the move/copy from/to. Content transfer (download/upload) is a raw streamed +// body outside huma, so it has no generated type — see useFiles.ts. +export type FileEntry = Schemas["FileEntry"]; +export type FileLocation = Schemas["FileLocation"]; // Scope is a UI-side literal union, intentionally NOT generated. The brain // serves scope (like severity / status / state) as a free string — the huma diff --git a/web-ui/src/components/FileDestinationDialog.vue b/web-ui/src/components/FileDestinationDialog.vue new file mode 100644 index 00000000..b1546efe --- /dev/null +++ b/web-ui/src/components/FileDestinationDialog.vue @@ -0,0 +1,139 @@ + + + diff --git a/web-ui/src/generated/openapi.ts b/web-ui/src/generated/openapi.ts index 4e5569f5..ae1340b8 100644 --- a/web-ui/src/generated/openapi.ts +++ b/web-ui/src/generated/openapi.ts @@ -364,6 +364,91 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/files/copy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Copy a file or folder */ + post: operations["files-copy"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/files/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete a file or folder */ + post: operations["files-delete"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/files/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List a directory in the file manager */ + post: operations["files-list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/files/mkdir": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a folder */ + post: operations["files-mkdir"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/files/move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Move or rename a file or folder */ + post: operations["files-move"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/health": { parameters: { query?: never; @@ -1068,6 +1153,77 @@ export interface components { */ type: string; }; + FileEntry: { + dir: boolean; + hidden: boolean; + mtime: string; + name: string; + /** Format: int64 */ + size_bytes: number; + }; + FileLocation: { + path: string; + root: string; + }; + "Files-copyRequest": { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example https://example.com/schemas/Files-copyRequest.json + */ + readonly $schema?: string; + from: components["schemas"]["FileLocation"]; + to: components["schemas"]["FileLocation"]; + }; + "Files-deleteRequest": { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example https://example.com/schemas/Files-deleteRequest.json + */ + readonly $schema?: string; + path: string; + root: string; + }; + "Files-listRequest": { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example https://example.com/schemas/Files-listRequest.json + */ + readonly $schema?: string; + path: string; + root: string; + }; + "Files-mkdirRequest": { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example https://example.com/schemas/Files-mkdirRequest.json + */ + readonly $schema?: string; + path: string; + root: string; + }; + "Files-moveRequest": { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example https://example.com/schemas/Files-moveRequest.json + */ + readonly $schema?: string; + from: components["schemas"]["FileLocation"]; + to: components["schemas"]["FileLocation"]; + }; + FilesListResponse: { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example https://example.com/schemas/FilesListResponse.json + */ + readonly $schema?: string; + entries: components["schemas"]["FileEntry"][] | null; + }; FolderElection: { folder: string; source?: string; @@ -2387,6 +2543,163 @@ export interface operations { }; }; }; + "files-copy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Files-copyRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "files-delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Files-deleteRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "files-list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Files-listRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FilesListResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "files-mkdir": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Files-mkdirRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; + "files-move": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Files-moveRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorModel"]; + }; + }; + }; + }; "list-health-issues": { parameters: { query?: never; diff --git a/web-ui/src/useFiles.ts b/web-ui/src/useFiles.ts new file mode 100644 index 00000000..0a3f24d9 --- /dev/null +++ b/web-ui/src/useFiles.ts @@ -0,0 +1,112 @@ +// File-manager API layer (FILES.md). Metadata ops go through the JSON api.ts +// wrapper; content transfer (download/upload) bypasses it — the wrapper always +// JSON-encodes bodies and parses JSON responses, which cannot carry a streamed +// File body or a binary download. Download is a same-origin hit +// (cookie rides along); upload is an XHR PUT so the browser reports progress +// (fetch has no upload-progress event). +import { api, ApiError, type FileEntry, type FileLocation } from "@/api"; + +export type FileRoot = "home" | "shared"; + +export interface FileListing { + entries: FileEntry[]; +} + +export function listFiles(root: FileRoot, path: string): Promise { + return api.post("/files/list", { root, path }); +} + +export function makeFolder(root: FileRoot, path: string): Promise { + return api.post("/files/mkdir", { root, path }); +} + +export function deleteEntry(root: FileRoot, path: string): Promise { + return api.post("/files/delete", { root, path }); +} + +export function moveEntry(from: FileLocation, to: FileLocation): Promise { + return api.post("/files/move", { from, to }); +} + +export function copyEntry(from: FileLocation, to: FileLocation): Promise { + return api.post("/files/copy", { from, to }); +} + +// joinPath appends a segment to a relative path, keeping "" for a root listing. +export function joinPath(path: string, name: string): string { + return path ? `${path}/${name}` : name; +} + +// parentPath returns the path one level up ("" at a root). +export function parentPath(path: string): string { + const i = path.lastIndexOf("/"); + return i === -1 ? "" : path.slice(0, i); +} + +// downloadURL is the same-origin content endpoint. An GETs it with +// the session cookie; the brain's Content-Disposition names the saved file. +export function downloadURL(root: FileRoot, path: string): string { + const q = new URLSearchParams({ root, path }); + return `/api/v1/files/content?${q.toString()}`; +} + +// uploadFile streams a File to the content endpoint via XHR, reporting progress. +// Resolves on 2xx; rejects with an ApiError carrying the brain's {code,message}. +export function uploadFile( + root: FileRoot, + path: string, + file: File, + onProgress?: (pct: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const q = new URLSearchParams({ root, path }); + xhr.open("PUT", `/api/v1/files/content?${q.toString()}`); + xhr.withCredentials = true; + if (onProgress) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); + }; + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + return; + } + let code = "upload_failed"; + let message = xhr.statusText || "Upload failed"; + try { + const body = JSON.parse(xhr.responseText); + code = body.code ?? code; + message = body.message ?? message; + } catch { + // non-JSON error body; keep the status text + } + reject(new ApiError(code, message, xhr.status)); + }; + xhr.onerror = () => reject(new ApiError("network_error", "Upload failed", 0)); + xhr.send(file); + }); +} + +// formatBytes renders a human size for the listing (files only; dirs show "—"). +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let size = n / 1024; + let i = 0; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`; +} + +// sortEntries orders a listing folders-first, then case-insensitive by name — +// the conventional file-manager order. +export function sortEntries(entries: FileEntry[]): FileEntry[] { + return [...entries].sort((a, b) => { + if (a.dir !== b.dir) return a.dir ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); +} diff --git a/web-ui/src/views/FilesView.vue b/web-ui/src/views/FilesView.vue index c2e02d1d..57d3500d 100644 --- a/web-ui/src/views/FilesView.vue +++ b/web-ui/src/views/FilesView.vue @@ -1,18 +1,352 @@