build: hand containerised build artifacts back to the host user

The compose services bind-mount the repo and build as root, so every artifact
they leave in src-tauri/target belongs to root on the host. It accumulates:
11,124 such files had built up, enough that cargo clean and scripts/clean.sh
failed with EACCES — and a plain cargo build died part-way through, because
build scripts compile for the host and land in target/debug even when
cross-compiling to Android. That is what blocked the device build in this batch.

Restores ownership at the end of each containerised build, reading the intended
owner from the checkout so no uid has to be plumbed through from the host. A
no-op when not running as root, so the native build scripts call it
unconditionally.

Running the containers as the host uid is the tidier fix and stays open — it
needs the cargo/bun cache volumes moved off /root first, which is why this is
not a one-line user: directive.

TRACES: | DR-213
This commit is contained in:
2026-08-20 20:17:36 +02:00
parent 68ca1d585d
commit 2a3f08f8a4
6 changed files with 67 additions and 1 deletions
+1
View File
@@ -402,6 +402,7 @@ Internal architecture, components, and application logic.
| DR-210 | Thumbnail cache writes are confined to the cache directory. The filename was built from `item_id`, `image_type` and `tag`, but only `tag` was sanitised — and `Path::join` neither folds `..` nor keeps the base when handed an absolute path, so a value arriving verbatim from server JSON decided where a file landed. The tag's existing rule (non-alphanumerics become `_`) now applies to all three parts, and the resolved path is checked with `starts_with(cache_dir)` at the point of use. The database keeps the raw key and the resolved path, so lookups still match and pre-existing rows still resolve. Not exploitable as shipped — server URLs must be HTTPS and Android blocks cleartext, so the id comes from a server the user chose to trust — the value is making the write path consistent with how caller-supplied paths are handled elsewhere | Storage | UR-012 | Done |
| DR-211 | Download paths are confined to the download root. `file_path` and `target_dir` reached `PathBuf::join` unchecked from the frontend, and `mark_download_completed` persisted a caller-supplied path later passed to `remove_file`. A correct sanitiser already existed and `download_item_and_start` used it, but `download_item` is itself a command accepting `file_path` raw, so the guard was bypassable rather than absent — the fix moves it inside instead of adding a second one. Sanitising is **per path component**: whole-string sanitising would rewrite `downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download. Confinement happens after the join, since a join with an absolute second half discards the root | Downloads | UR-011 | Done |
| DR-212 | Query and URL construction bind or encode their inputs. Three sites interpolated caller-supplied values directly: the offline `get_items` item-type filter built `IN ('a','b')` by string formatting, `build_get_items_endpoint` wrote `ParentId`/`IncludeItemTypes`/`SortBy`/`SortOrder` into a URL unencoded, and `player_set_volume` accepted NaN and out-of-range floats. Each is a *consistency* defect rather than a novel one — the same file already did it correctly a few lines away (parameter placeholders in `search`, `urlencoding::encode` for genres, `clamp` in every player backend). List separators stay unencoded and encoding is per element, because Jellyfin splits these parameters on the comma | Repository | UR-007, UR-065 | Done |
| DR-213 | Containerised builds hand their artifacts back to the host user. The compose services bind-mount the repo and run as root — their caches live at `/root/.cargo` and `/root/.bun`, so a non-root container user cannot write them — which leaves root-owned files accumulating in the developer's working tree: 11,124 of them when this was found, enough that `cargo clean` and `scripts/clean.sh` failed with EACCES and a plain `cargo build` died part-way, since build scripts compile for the host and land in `target/debug` even during a cross-build. Ownership is restored at the end of each containerised build, reading the intended owner from the checkout so no uid needs plumbing through. Running the containers as the host uid is the tidier fix and remains open; it needs the cache volumes relocated off `/root` first | Tooling | - | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
+1 -1
View File
@@ -1,6 +1,6 @@
# Code Traceability Matrix
**Generated:** 8/20/2026, 8:14:07 PM
**Generated:** 8/20/2026, 8:17:35 PM
## Summary
+4
View File
@@ -115,3 +115,7 @@ fi
echo ""
echo "✅ APK build complete!"
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
# Containerised builds run as root against a bind-mounted tree; hand the
# artifacts back to the host user. No-op when not root. See DR-213.
"$(dirname "$0")/restore-ownership.sh"
+4
View File
@@ -42,3 +42,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
echo ""
echo "📦 Copied bundles to $OUTPUT_DIR"
fi
# Containerised builds run as root against a bind-mounted tree; hand the
# artifacts back to the host user. No-op when not root. See DR-213.
"$(dirname "$0")/restore-ownership.sh"
+4
View File
@@ -67,3 +67,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
echo ""
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
fi
# Containerised builds run as root against a bind-mounted tree; hand the
# artifacts back to the host user. No-op when not root. See DR-213.
"$(dirname "$0")/restore-ownership.sh"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Give build artifacts back to the human who owns the working tree.
#
# TRACES: | DR-213
#
# The containerised builds (docker-compose.yml: desktop-linux-build,
# windows-cross, android-build, test, dev) bind-mount the repo at /app and run
# as root, because their caches live at /root/.cargo and /root/.bun. Everything
# they write into src-tauri/target and dist/ is therefore root-owned *on the
# host* — and it accumulates: one audit found 11,124 such files, which is enough
# to make `cargo clean` and scripts/clean.sh fail with EACCES for the developer.
# Worse, a plain `cargo build` then dies part-way through, because build scripts
# compile for the host and land in target/debug even during a cross-build.
#
# Running the containers as the host uid would be the tidier fix, but it needs
# the cache volumes relocated off /root first. Until that happens, this restores
# ownership at the end of each containerised build, which is self-healing and
# needs no uid plumbing on the host side.
#
# Outside a container this is a no-op: it exits immediately unless it is running
# as root, so the native build scripts can call it unconditionally.
set -uo pipefail
# Not root (a normal developer build) — nothing to fix, and nothing we may fix.
[ "$(id -u)" -eq 0 ] || exit 0
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
# Whoever owns the checkout is who the artifacts should belong to. Reading it
# from the tree means this works for any uid/gid without being told, including
# CI runners whose uid we do not control.
OWNER="$(stat -c '%u:%g' "$REPO_ROOT")"
# uid 0 owning the tree means it is not a bind mount from a normal host account
# (a root-owned checkout, or a CI image that clones as root). Nothing to give back.
if [ "${OWNER%%:*}" = "0" ]; then
exit 0
fi
echo ""
echo "🔑 Restoring ownership of build artifacts to ${OWNER}"
for target in src-tauri/target src-tauri/gen dist build node_modules .svelte-kit; do
[ -e "$REPO_ROOT/$target" ] || continue
chown -R "$OWNER" "$REPO_ROOT/$target" 2>/dev/null || {
echo "⚠️ Could not fully chown $target — you may need:"
echo " sudo chown -R $OWNER $REPO_ROOT/$target"
}
done
echo "✅ Ownership restored."