Skip to content

feat(stream): http range streaming + hmac signed urls - #18

Merged
InstaZDLL merged 5 commits into
mainfrom
feat/1-e-1-stream-server
May 31, 2026
Merged

feat(stream): http range streaming + hmac signed urls#18
InstaZDLL merged 5 commits into
mainfrom
feat/1-e-1-stream-server

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 1.e.1. Adds the streaming surface so the browser can play tracks the server holds. Two endpoints, split across the auth boundary so the browser can use <audio src> without juggling a Bearer header.

Endpoint Auth Notes
POST /api/v1/.../tracks/{t}/stream-url JWT Verifies tenant ownership, signs a short-lived URL. Returns { url, expires_at }.
GET /api/v1/stream/{token} HMAC Mounted OUTSIDE the JWT layer. Token IS the auth. Supports Range: bytes=....

Net +1237 LOC.

Architecture

  • Token format: base64url(json{p, exp}) . base64url(hmac_sha256(secret, json)). Constant-time verify_slice. Max lifetime 60 s.
  • Path safety: std::fs::canonicalize(<music_root>/<file_path>) + prefix check. ../ and symlinks pointing outside the root both 404.
  • Range handling: hand-rolled (not tower-http::services::ServeFile) so we resolve a single canonical file per token rather than expose a directory. Accept-Ranges: bytes, 206 + Content-Range on partial, 416 on unsatisfiable.
  • MIME: switched on extension (mp3 / flac / wav / ogg / opus / m4a / aac / aiff). Default application/octet-stream.

New code

  • src/stream_token.rs — mint + verify HMAC tokens (6 unit tests: round-trip, tampered payload, tampered expiry, wrong secret, expired, shape errors)
  • src/api/stream.rs — both handlers + range parser + path resolver (6 unit tests on range parsing alone: closed, open-end, suffix, clamping, malformed, multi-range rejected)
  • tests/stream.rs — 8 integration tests covering full body, range slice, open-end range, out-of-range 416, tampered token 401, foreign-user mint 401, path traversal 404, streaming-disabled 503

Config

Two new envs, both required together or both unset:

WAVEFLOW_MUSIC_ROOT=/var/lib/waveflow/music
WAVEFLOW_STREAM_SECRET=$(openssl rand -base64 32)

Half-set is a footgun — boot bails with a clear error. Unset disables both endpoints cleanly with 503 (so a deploy with no music yet still boots).

Test plan

  • cargo check --workspace --all-targets — clean
  • cargo fmt --all --check — clean
  • cargo test --lib stream — 12/12 pass (range parser + HMAC token)
  • cargo test --test stream — needs live Postgres + tempfile dirs, CI validates

Manual smoke after merge (with both servers up):

# 1. Mint
curl -X POST -H "Authorization: Bearer <jwt>" \
    http://localhost:4000/api/v1/profiles/1/libraries/1/tracks/1/stream-url
# → { "url": "/api/v1/stream/eyJ...", "expires_at": 1735603260 }
# 2. Play
curl -i http://localhost:4000/api/v1/stream/eyJ... -H "Range: bytes=0-99"
# → 206 Partial Content + Content-Range: bytes 0-99/N

What this is NOT

  • ❌ No upload endpoint — files must be placed manually under WAVEFLOW_MUSIC_ROOT. The desktop → server sync lands in 1.f.
  • ❌ No transcoding — bytes are served as-is. The browser handles codec choice via the MIME hint.
  • ❌ No range streaming for partial responses — partial reads buffer into memory then send. Audio scrubbing chunks are small (<1 MB usually), so the perf hit is invisible. Revisit if range sizes grow.
  • ❌ No CDN signing — the HMAC URL is server-local. CloudFront-style URL pre-signing is a 1.h optimisation.

Follow-up

1.e.2 (waveflow-web) ships the player UI + the getStreamUrl(track_id) server-fn that hits the mint endpoint.

Summary by CodeRabbit

  • Nouvelles fonctionnalités

    • Streaming audio via URLs temporaires signées (mint JWT + endpoint public HMAC-signé)
    • Prise en charge des requêtes partielles (Range) avec réponses 200/206/416
    • Routes OpenAPI pour le mint et le streaming
  • Documentation

    • Section "Streaming (Phase 1.e)" et exemple .env décrivant WAVEFLOW_MUSIC_ROOT et WAVEFLOW_STREAM_SECRET
    • Comportement : endpoint renvoie 503 si le streaming n’est pas configuré
  • Tests

    • Tests unitaires et e2e couvrant mint, streaming, Range, traversée et mode désactivé

Phase 1.e.1. Adds the streaming surface so the browser can play
tracks the server holds. Two endpoints, split across the auth
boundary:

- POST /api/v1/profiles/{p}/libraries/{l}/tracks/{t}/stream-url
  JWT-authed. Verifies tenant ownership of the track, then
  mints a signed URL via HMAC-SHA256 with 60s lifetime. Returns
  { url, expires_at }.
- GET /api/v1/stream/{token} — mounted OUTSIDE the JWT layer
  because browsers can't attach a Bearer header to <audio src>.
  The HMAC in the token IS the auth. Canonicalises file_path
  under WAVEFLOW_MUSIC_ROOT, refuses anything resolving outside
  (path-traversal guard via std::fs::canonicalize + prefix check).
  Range-aware: Accept-Ranges: bytes, 206 + Content-Range on
  partial, 416 on unsatisfiable.

New modules:
- src/stream_token.rs: mint + verify HMAC tokens. Format is
  base64url(json{p, exp}).base64url(hmac_sha256(secret, json)).
  Constant-time verify_slice. 6 unit tests cover round-trip,
  tampered payload, tampered expiry, wrong secret, expired,
  shape errors.
- src/api/stream.rs: both endpoint handlers + parse_range +
  resolve_path. 6 unit tests cover range parsing (closed,
  open, suffix, clamping, malformed, multi-range rejected).

Config + state:
- WAVEFLOW_MUSIC_ROOT + WAVEFLOW_STREAM_SECRET both required
  together or both unset (half-set is a footgun, bails at boot).
  Unset disables both endpoints cleanly with 503.
- AppState.stream_ctx: Option<Arc<StreamCtx>> built once at boot
  from the canonicalised music root + HMAC key.

Tests:
- 12 unit tests pass locally
- 8 integration tests in tests/stream.rs: full 200, range 206,
  open-end range, out-of-range 416, tampered token 401, foreign
  user 401, path traversal 404, streaming-disabled 503. Use
  tempfile::TempDir for per-test scratch music roots.
- openapi.rs asserts both new paths land in the spec

Net +1237 LOC. cargo check + cargo fmt --check + lib tests all
green. Integration tests need Postgres so CI validates them.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b07480b3-8ef9-4056-9fb5-4127cb046dd5

📥 Commits

Reviewing files that changed from the base of the PR and between 699ff81 and b95a090.

📒 Files selected for processing (1)
  • tests/stream.rs

📝 Walkthrough

Walkthrough

Ce PR ajoute le mint JWT d’un token signé HMAC-SHA256 et un endpoint public GET qui vérifie le token, canonicalise le chemin sous WAVEFLOW_MUSIC_ROOT pour bloquer la traversée, et sert les fichiers audio avec support des Range (200/206/416) ou 503 si le streaming est désactivé.

Changes

Phase 1.e — Streaming avec signatures HMAC

Layer / File(s) Résumé
Configuration et bootstrap
.env.example, CLAUDE.md, Cargo.toml, src/config.rs, src/lib.rs, src/main.rs
Ajout de WAVEFLOW_MUSIC_ROOT et WAVEFLOW_STREAM_SECRET, promotion de base64 en runtime et ajout de hmac/sha2/tokio-util, champs Config.music_root/stream_secret, export StreamCtx et initialisation conditionnelle du AppState.stream_ctx.
Token signé (HMAC-SHA256)
src/stream_token.rs
StreamClaim { p, exp }; mint sérialise et signe HMAC-SHA256 puis encode payload.signature en base64url ; verify décode, vérifie HMAC constant-time et valide l'expiration ; tests unitaires.
Endpoints mint et serve
src/api/stream.rs
mint_stream_url (JWT) lookup tenant-scoped, génère token court et retourne {url, expires_at} ; stream_audio (public) vérifie token, résout/canonicalise file_path sous music_root, lit metadata, parse Range, sert complet (200) ou partiel (206) et gère erreurs (401/404/416/503).
Routage API
src/api/mod.rs
Déclaration du module stream, montage d’un routeur auth (mint) derrière JWT et d’un routeur public (serve) exposés dans le router OpenAPI principal.
Tests E2E et OpenAPI
tests/support.rs, tests/stream.rs, tests/openapi.rs
Helper spawn_app_with_jwt_and_stream et suite E2E vérifiant streaming complet, Range bornée/ouverte/invalide, token falsifié, multi-tenant, anti-traversal (../) et mode désactivé (503). OpenAPI mis à jour pour les deux routes.

Sequence Diagram

sequenceDiagram
  participant Client
  participant MintHandler as Mint Handler
  participant Repo as Track Repo
  participant StreamToken as stream_token
  participant ServeHandler as Serve Handler
  participant FileSystem as FileSystem

  Client->>MintHandler: POST /stream-url + Authorization (JWT)
  MintHandler->>Repo: lookup track (tenant-scoped)
  MintHandler->>StreamToken: mint(secret, claim)
  MintHandler-->>Client: { url, expires_at }

  Client->>ServeHandler: GET /api/v1/stream/{token} (Range?)
  ServeHandler->>StreamToken: verify(secret, token, now)
  ServeHandler->>FileSystem: resolve_path + canonicalize
  FileSystem-->>ServeHandler: metadata (len)
  alt Range present
    ServeHandler->>FileSystem: open + seek + read(part)
    ServeHandler-->>Client: 206 + Content-Range
  else no Range
    ServeHandler->>FileSystem: stream full file
    ServeHandler-->>Client: 200 + full body
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • InstaZDLL/waveflow-server#4: chevauchement sur la composition du routeur OpenAPI et l’agrégation de sous-routeurs — le nouveau routeur stream s’y greffe.
  • InstaZDLL/waveflow-server#14: modifications récentes du routage/auth (middleware) auxquelles ce PR se raccorde pour monter le mint JWT.
  • InstaZDLL/waveflow-server#17: changements liés à l’authentification/middleware dont dépend le montage du routeur mint (tenant-scoped).

Poem

🎵 Un jeton scellé pour l’onde en mouvement,
HMAC garde l’heure, verrouille le moment,
Chemin canonique, pas de fuite en arrière,
Range répond 206 pour l’écoute volontaire,
Le flux chante, contrôlé et transparent.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Le titre décrit précisément les deux éléments clés du changement : le streaming HTTP avec support des Range requests et les URL signées HMAC.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1-e-1-stream-server

Comment @coderabbitai help to get the list of available commands and usage tips.

@InstaZDLL InstaZDLL self-assigned this May 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/config.rs (1)

13-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Redacter stream_secret dans Debug de Config

src/config.rs dérive Debug (#[derive(Debug, Clone)]) et contient stream_secret: Option<Vec<u8>> : tout {:?}/log qui formaterait un Config exposerait les octets du secret. AppState évite bien Debug, mais StreamCtx dérive aussi Debug et porte le secret (src/lib.rs), donc fuite possible si stream_ctx est jamais loggé en Debug.

🔒 Piste : Debug manuel qui masque le secret (au minimum pour Config) :

🔒 Debug manuel qui masque le secret
-#[derive(Debug, Clone)]
+#[derive(Clone)]
 pub struct Config {
impl std::fmt::Debug for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Config")
            .field("bind_addr", &self.bind_addr)
            .field("request_timeout_secs", &self.request_timeout_secs)
            .field("database_url", &self.database_url)
            .field("db_max_connections", &self.db_max_connections)
            .field("jwt_jwks_url", &self.jwt_jwks_url)
            .field("jwt_issuer", &self.jwt_issuer)
            .field("jwt_audience", &self.jwt_audience)
            .field("music_root", &self.music_root)
            .field(
                "stream_secret",
                &self.stream_secret.as_ref().map(|_| "<redacted>"),
            )
            .finish()
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.rs` at line 13, The Config type currently derives Debug and
exposes stream_secret: Option<Vec<u8>>; replace the derived Debug for Config by
implementing std::fmt::Debug manually (impl std::fmt::Debug for Config) and
format every field as before but render stream_secret as a redacted value (e.g.
map Some(_) to "<redacted>"); also check StreamCtx (which derives Debug and
carries the same secret) and either remove its Debug derive or provide a similar
manual Debug impl that redacts the secret to prevent secrets from appearing in
logs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/stream.rs`:
- Line 306: La fonction resolve_path (et de même guess_mime) doit accepter &Path
au lieu de &PathBuf pour corriger l'avertissement clippy::ptr_arg : changez les
signatures de fn resolve_path(music_root: &PathBuf, rel: &str) ->
Result<PathBuf, StatusCode> et de fn guess_mime(path: &PathBuf) -> Mime en fn
resolve_path(music_root: &Path, rel: &str) -> Result<PathBuf, StatusCode> et fn
guess_mime(path: &Path) -> Mime, puis adaptez tous les appels (par exemple là où
vous passez un &music_root ou une PathBuf) à emprunter en tant que .as_path() ou
&*path_buf; conservez l'utilisation des méthodes Path/PathBuf inchangées à
l'intérieur des fonctions et importez std::path::Path si nécessaire.
- Around line 312-322: The call to blocking std::fs::canonicalize on the Tokio
runtime must be replaced: make resolve_path async (change signature of
resolve_path to async fn resolve_path(...)) and inside it call
tokio::fs::canonicalize(&candidate).await (or, if you prefer another approach,
run std::fs::canonicalize inside tokio::task::spawn_blocking and await the
JoinHandle), handle the Result as before and return the PathBuf; then update the
caller stream_audio to await resolve_path(...).await and propagate errors
accordingly so no synchronous filesystem call runs on the runtime.
- Around line 231-241: serve_partial currently allocates len = end - start + 1
into buf which allows unbounded OOM via large Range requests; fix by enforcing a
MAX_CHUNK (e.g. 8 * 1024 * 1024) and clamping len = min(len, MAX_CHUNK) before
allocating buf, update the response to return 206 and set the correct
Content-Range header reflecting the clamped window, and use file.seek/start
offset then read only the clamped amount via file.read_exact; alternatively
(preferable for large files) replace the buffered read with a streaming approach
using ReaderStream::with_capacity and tokio::io::AsyncReadExt::take (or
std::io::Take) on the file to stream at most the clamped length without full
allocation—changes touch serve_partial, len, buf, file.read_exact and the
response construction/headers.

In `@src/lib.rs`:
- Around line 80-87: The struct StreamCtx doc comment contains a leading '+' at
the start of a line which triggers clippy::doc-lazy-continuation and should be
reworded to remove that '+'; also do not derive Debug on StreamCtx (it leaks the
HMAC key in the secret: Vec<u8> field) — remove #[derive(Debug)] and either omit
Debug entirely or implement a manual Debug for StreamCtx that redacts or omits
the secret field (showing only music_root), following the same discipline used
for AppState.

In `@src/stream_token.rs`:
- Around line 33-35: La doc-commentaire contenant "Canonicalisation" et la
vérification de traversal (associée au type StreamToken) utilise un signe "+" au
début d'une ligne, ce qui est interprété comme une puce Markdown ; modifie la
phrase pour éviter commencer une ligne par "+" (par ex. remplace "+" par "et" ou
"ainsi que", reformule la phrase complète) afin que la doc-commentaire du struct
StreamToken ne génère plus l'avertissement clippy::doc-lazy-continuation.

In `@tests/stream.rs`:
- Around line 280-304: The test foreign_user_cannot_mint currently fails at JWT
verification because other.token is from a different harness; change it to
exercise tenant-authorization by creating two users whose tokens are signed by
the same harness that the streaming app uses (instead of spawn_authenticated
producing a different harness), e.g. add or use a helper like spawn_*_and_stream
(or spawn_authenticated_sharing_harness) that returns an authenticated user and
a streaming app instance using the same harness, then call the POST to
/api/v1/profiles/{}/libraries/{}/tracks/{}/stream-url with the non-owner's valid
token and assert the request is rejected at the authorization layer (expect
StatusCode::FORBIDDEN or NOT_FOUND) rather than UNAUTHORIZED; update test
foreign_user_cannot_mint to use bootstrap and the new helper (or reconfigure
spawn_authenticated) so tokens and streaming app share the same JWKS before
asserting 403/404.
- Around line 5-6: La docstring en tête de tests/stream.rs affirme couvrir le
cas « expired token » alors qu'aucun test d'expiration n'existe; soit supprime
la mention « expired token » de la docstring, soit ajoute un test explicite
(p.ex. nommer-le test_expired_token) qui mint un token, attend que l'expire (ou
manipule le champ exp), fait la requête de stream et vérifie le rejet (401/403)
— modifiez la description en haut du fichier ou ajoutez le test pour que la
docstring reflète réellement la couverture.
- Around line 264-275: The test mutates the last base64url character of the
signature (chars, tampered) which can be padding/insignificant; change the flip
to target the penultimate character (use index chars.len() - 2) so the modified
base64url definitely changes the underlying HMAC bytes and makes the request
fail; also guard with a short-circuit check that chars.len() >= 2 to avoid
panics when computing the penultimate index.

---

Outside diff comments:
In `@src/config.rs`:
- Line 13: The Config type currently derives Debug and exposes stream_secret:
Option<Vec<u8>>; replace the derived Debug for Config by implementing
std::fmt::Debug manually (impl std::fmt::Debug for Config) and format every
field as before but render stream_secret as a redacted value (e.g. map Some(_)
to "<redacted>"); also check StreamCtx (which derives Debug and carries the same
secret) and either remove its Debug derive or provide a similar manual Debug
impl that redacts the secret to prevent secrets from appearing in logs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 318978b5-a7c1-4f9b-8942-02bb6c779b85

📥 Commits

Reviewing files that changed from the base of the PR and between 0b4d98c and 0403f2b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock, !*.lock
📒 Files selected for processing (12)
  • .env.example
  • CLAUDE.md
  • Cargo.toml
  • src/api/mod.rs
  • src/api/stream.rs
  • src/config.rs
  • src/lib.rs
  • src/main.rs
  • src/stream_token.rs
  • tests/openapi.rs
  • tests/stream.rs
  • tests/support.rs

Comment thread src/api/stream.rs Outdated
Comment thread src/api/stream.rs Outdated
Comment thread src/api/stream.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread src/stream_token.rs Outdated
Comment thread tests/stream.rs Outdated
Comment thread tests/stream.rs Outdated
Comment thread tests/stream.rs
Findings from CI clippy + CR review on PR #18:

clippy --all-targets --all-features -D warnings:
- guess_mime + resolve_path: &PathBuf -> &Path. Aliased
  std::path::Path as FsPath in the imports so axum's
  extract::Path stays unambiguous.
- stream_token.rs StreamClaim doc: reworded the bullet to drop
  the leading + that tripped doc_lazy_continuation.
- lib.rs StreamCtx doc: same fix, rephrased the multi-line bullet.

OOM hardening:
- serve_partial: clamped end to start + MAX_RANGE_BYTES - 1
  (8 MiB cap) so a hostile bytes=0-99999999 can no longer force a
  100 MB allocation. Switched the body from vec![0u8; len] +
  read_exact to AsyncReadExt::take + ReaderStream, so the
  response now streams the clamped window 64 KiB at a time. The
  Content-Range header reflects what we actually serve, not what
  the client asked for.

Runtime correctness:
- resolve_path: std::fs::canonicalize -> tokio::fs::canonicalize
  + made the fn async. The blocking call was technically OK on
  release builds but stalls the runtime worker on cold paths;
  the tokio variant hops to blocking-io pool.

Secret hygiene:
- StreamCtx: removed derive(Debug), added a hand-rolled impl
  that renders secret as '<redacted>'.
- Config: same treatment. The whole Config struct lands in the
  startup tracing line and any anyhow context produced during
  Config::from_env, so a derived Debug would leak the HMAC.

Tests:
- foreign_user_cannot_mint renamed to
  foreign_user_minting_a_foreign_track_404s and rewritten to
  use the SAME JwksHarness as the bootstrap. The previous version
  was actually exercising token verification (different harness =
  401), not tenant authorization. With a shared harness, the
  foreign token authenticates cryptographically and the
  *_for_user repository query refuses the row -> 404.
- tampered_token_is_rejected now flips the penultimate base64url
  character instead of the last one. The last position can fall
  on padding-equivalent bits in some edge cases; penultimate is
  always a payload bit.
- Module docstring no longer claims to cover 'expired token' (the
  unit test in stream_token::tests covers that path).

Lib tests 12/12 pass + cargo clippy --all-targets --all-features
-D warnings clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai tous les findings appliqués dans 62b8a2e. Récap par fichier :

src/api/stream.rs :

  • guess_mime + resolve_path : &PathBuf&Path. Aliasé std::path::Path as FsPath pour éviter la collision avec axum::extract::Path.
  • resolve_path : std::fs::canonicalizetokio::fs::canonicalize + fn devient async.
  • serve_partial : clamp end à start + MAX_RANGE_BYTES - 1 (8 MiB cap) + switch du body vec![0u8; len] + read_exact vers AsyncReadExt::take + ReaderStream. Plus de buffering full-window, streaming 64 KiB chunks, et Content-Range reflète la window servie (pas demandée).

src/lib.rs + src/config.rs :

  • StreamCtx : drop derive(Debug), impl manuelle qui redact secret<redacted>.
  • Config : même traitement (même surface de leak via tracing et anyhow context).

src/stream_token.rs :

  • Doc indentation fixée (drop du + lazy-continuation).

tests/stream.rs :

  • foreign_user_cannot_mint renommé foreign_user_minting_a_foreign_track_404s + rewrite pour utiliser le MÊME JwksHarness que le bootstrap. La version précédente testait token verification (différent harness = 401), pas tenant authorization. Avec harness partagée, le token foreign authentifie cryptographiquement et la query *_for_user repository refuse la row → 404.
  • tampered_token_is_rejected : flip de l'avant-dernier char au lieu du dernier (le dernier peut tomber sur des bits padding-equivalent en base64url no-pad). Guard url.len() >= 2.
  • Module docstring : drop la mention "expired token" (couvert par le unit test stream_token::tests::rejects_an_expired_token).

Validation : cargo clippy --all-targets --all-features -D warnings clean (le pipeline CI exact), cargo fmt --check clean, lib tests 12/12 pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/api/stream.rs (1)

336-346: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

music_root doit être canonicalisé au démarrage pour que la vérification fonctionne.

Si WAVEFLOW_MUSIC_ROOT contient un symlink (ex. /data/music/mnt/nas/music), le chemin canonicalisé du fichier sera /mnt/nas/music/song.flac mais music_root restera /data/music. Le starts_with échouera pour tous les fichiers valides.

Canonicaliser music_root lors de la construction de StreamCtx (dans main.rs ou la config) garantit une comparaison cohérente.

🛡️ Correctif suggéré (au boot, pas ici)

Dans main.rs ou lors de la création de StreamCtx :

let music_root = tokio::fs::canonicalize(&config.music_root.unwrap())
    .await
    .context("WAVEFLOW_MUSIC_ROOT must exist and be resolvable")?;

Ainsi StreamCtx.music_root est déjà canonique et la comparaison ligne 338 fonctionne correctement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/stream.rs` around lines 336 - 346, The check in StreamCtx (the
starts_with comparison in stream.rs) can fail when the configured music_root
contains symlinks because the code canonicalizes the requested file but not
StreamCtx.music_root; canonicalize the configured root at startup when
constructing StreamCtx (e.g., during config/main initialization where
WAVEFLOW_MUSIC_ROOT is read) using tokio::fs::canonicalize and store the
resulting PathBuf in StreamCtx.music_root so the later starts_with(canonical)
comparison works reliably, and surface a clear error/context if canonicalization
fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config.rs`:
- Around line 83-100: The Debug impl for Config currently redacts stream_secret
but leaves database_url exposed; update the Debug implementation for Config
(impl std::fmt::Debug for Config) to redact database_url as well by replacing
the .field("database_url", &self.database_url) usage with a redacted
representation (e.g., .field("database_url", &self.database_url.as_ref().map(|_|
"<redacted>")) or otherwise mapping to "<redacted>" when present) so credentials
in the database_url are not printed while keeping other fields unchanged.

---

Outside diff comments:
In `@src/api/stream.rs`:
- Around line 336-346: The check in StreamCtx (the starts_with comparison in
stream.rs) can fail when the configured music_root contains symlinks because the
code canonicalizes the requested file but not StreamCtx.music_root; canonicalize
the configured root at startup when constructing StreamCtx (e.g., during
config/main initialization where WAVEFLOW_MUSIC_ROOT is read) using
tokio::fs::canonicalize and store the resulting PathBuf in StreamCtx.music_root
so the later starts_with(canonical) comparison works reliably, and surface a
clear error/context if canonicalization fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 35905a36-6b6b-48ac-bc56-6459a79a2c89

📥 Commits

Reviewing files that changed from the base of the PR and between 0403f2b and 62b8a2e.

📒 Files selected for processing (5)
  • src/api/stream.rs
  • src/config.rs
  • src/lib.rs
  • src/stream_token.rs
  • tests/stream.rs

Comment thread src/config.rs
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

@InstaZDLL : Récap bien reçu, toutes les corrections sont solides.

Quelques notes rapides sur les points les plus critiques :

  • ReaderStream + 8 MiB cap : bon switch — plus de full-window buffer en mémoire, Content-Range cohérent avec la fenêtre réellement servie.
  • Redaction du secret dans Debug : correction essentielle, évite les leaks via tracing/anyhow.
  • Réécriture de foreign_user_minting_a_foreign_track_404s : la distinction 401 (auth) vs 404 (authz) est maintenant testée correctement — c'était le vrai gap.
  • Flip avant-dernier char : bon réflexe pour l'edge case base64url no-pad.

Tout est cohérent. 🐇✓

✅ Actions performed

Comments resolved and changes approved.

Two follow-up findings from CR on PR #18:

- src/config.rs Config Debug: database_url joins the redaction
  set. Postgres URLs typically embed credentials
  (postgres://user:pass@host/db), so a derived Debug would land
  the password in the startup tracing line and in any anyhow
  context that prints the config. Same opaque-by-default policy
  as stream_secret.
- tests/support.rs spawn_app_with_jwt_and_stream: canonicalise
  the music_root parameter before stashing it in StreamCtx.
  main.rs already does this at boot; the test helper was the only
  remaining path that took a raw PathBuf. macOS resolves /tmp to
  /private/tmp via symlink, so without the canonicalisation every
  test fails its prefix-check (canonicalize(<tmp>/song.flac)
  starts with /private/tmp, not /tmp).

Lib tests 12/12 pass, cargo clippy -D warnings clean, fmt clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai les deux findings appliqués dans 5b9edfb :

src/config.rs Config Debug (inline) : database_url rejoint le set de redaction. Postgres URLs embedde typiquement les creds (postgres://user:pass@host/db), donc même politique opaque-par-défaut que stream_secret. Pas de parse-and-mask sur le segment password — la redaction du champ entier est plus safe.

src/api/stream.rs music_root canonicalisation (outside-diff) : Vérifié — main.rs canonicalise déjà au boot (tokio::fs::canonicalize sur WAVEFLOW_MUSIC_ROOT, abort startup si la dir n'existe pas). Mais le test helper spawn_app_with_jwt_and_stream dans tests/support.rs prenait une PathBuf brute, donc sur macOS où /tmp → /private/tmp, le prefix-check échouait pour chaque test (canonicalize(/tmp/.../song.flac) commence par /private/tmp, pas /tmp). Fix : canonicalize dans le helper test aussi pour mirror le boot prod.

Validation : cargo clippy --all-targets --all-features -D warnings clean, cargo fmt --check clean, lib tests 12/12 pass.

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL : Les deux corrections sont solides.

  • database_url redaction : choix correct — redacter le champ entier évite tout risque de regex mal placée sur le segment password. Cohérent avec la politique stream_secret.
  • Canonicalize dans le helper test : c'est le vrai fix — le bug macOS /tmp → /private/tmp ne se manifeste qu'en test (le boot prod canonicalize déjà), donc l'aligner dans spawn_app_with_jwt_and_stream est exactement la bonne couche. Sans ça, le prefix-check aurait pu masquer des vrais positifs en CI macOS.

Validation 12/12 + clippy/fmt propres — tout est cohérent. 🐇✓

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/config.rs (1)

163-172: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Refuser WAVEFLOW_STREAM_SECRET vide (et valider une longueur minimale) avant d’activer le streaming

Config::from_env accepte WAVEFLOW_STREAM_SECRET="" : std::env::var renvoie Ok(""), ce qui devient Some(vec![]) et fait démarrer StreamCtx avec une clé HMAC de longueur 0. Une telle configuration doit être refusée au boot (au minimum rejeter les chaînes vides, idéalement appliquer une longueur minimale cohérente avec la doc-comment).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.rs` around lines 163 - 172, Config::from_env currently accepts
WAVEFLOW_STREAM_SECRET="" (producing Some(vec![])) which allows a zero-length
HMAC key; change the handling of the stream_secret local so empty strings are
rejected and a minimum length is enforced: when reading WAVEFLOW_STREAM_SECRET
in Config::from_env (the stream_secret variable), treat empty string as invalid
and if present check its byte length against a MIN_STREAM_SECRET_LEN (or the
documented length) and return an error via anyhow::bail when the secret is too
short; keep the existing mutual-presence check with music_root but ensure the
error message clearly states the secret is missing or too short so StreamCtx is
never started with an invalid key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/config.rs`:
- Around line 163-172: Config::from_env currently accepts
WAVEFLOW_STREAM_SECRET="" (producing Some(vec![])) which allows a zero-length
HMAC key; change the handling of the stream_secret local so empty strings are
rejected and a minimum length is enforced: when reading WAVEFLOW_STREAM_SECRET
in Config::from_env (the stream_secret variable), treat empty string as invalid
and if present check its byte length against a MIN_STREAM_SECRET_LEN (or the
documented length) and return an error via anyhow::bail when the secret is too
short; keep the existing mutual-presence check with music_root but ensure the
error message clearly states the secret is missing or too short so StreamCtx is
never started with an invalid key.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 14edf963-3feb-4ec1-9e1f-52cf42fd28d1

📥 Commits

Reviewing files that changed from the base of the PR and between 62b8a2e and 5b9edfb.

📒 Files selected for processing (2)
  • src/config.rs
  • tests/support.rs

Two findings from CR + CI on PR #18:

- src/config.rs from_env: WAVEFLOW_STREAM_SECRET="" used to slip
  past the structural check (std::env::var returns Ok("") for an
  exported-but-empty var, then into_bytes() gives a zero-byte
  HMAC key). Treat empty strings as if the var were unset for the
  mutual-presence check, then enforce a 32-byte floor (the size
  openssl rand -base64 32 produces). Same filter applied to
  WAVEFLOW_MUSIC_ROOT for symmetry.
- tests/stream.rs path_traversal_attempts_404 direct INSERT used
  the wrong column name created_at, the schema declares added_at
  (cf. migrations/20260530000003_track.sql). CI surfaced this as
  a 42703 column-not-found. Fixed.

cargo clippy -D warnings clean, fmt clean, lib tests 18/18.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai les deux findings appliqués dans 699ff81 :

src/config.rs empty stream secret (outside-diff) : WAVEFLOW_STREAM_SECRET="" glissait à travers parce que std::env::var retourne Ok("") pour une var exportée mais vide, puis into_bytes() donnait vec![]. Fix : .filter(|s| !s.is_empty()) avant .map(...) sur les deux env vars (symétrique pour MUSIC_ROOT), puis enforcement d''un floor de 32 bytes sur le secret (taille de openssl rand -base64 32 que la doc recommande). Une chaîne plus courte → anyhow::bail! au boot avec message explicite.

CI fail path_traversal_attempts_404 : le INSERT direct dans track utilisait created_at mais le schema déclare added_at (cf. migrations/20260530000003_track.sql). Fixé.

Validation : cargo clippy --all-targets --all-features -D warnings clean, cargo fmt --check clean, 18/18 lib tests pass. Tests d''intégration → CI valide.

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL : Les deux corrections sont solides.

  • Empty secret bypass : le vec![] comme clé HMAC était le vrai risque — n'importe quelle implémentation HMAC avec une clé vide est triviale à reproduire. Le double filtre (empty + floor 32 bytes) + bail! explicite au boot est la bonne approche. Symétrique pour MUSIC_ROOT, ce qui est cohérent.
  • created_atadded_at : mismatch de schema dans un INSERT direct — classique CI fail silencieux en dev local si la migration n'a pas tourné dans le bon ordre. Bon catch.

18/18 + CI vert — tout est propre. 🐇✓

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/stream.rs`:
- Around line 337-345: L’INSERT en test utilise une chaîne codée pointant vers
"../../../etc/passwd" (via la variable evil_rel) au lieu d’utiliser le fichier
temporaire créé dans le test, rendant le test non déterministe; changez
l’argument lié au champ file_path dans la requête SQL pour binder la valeur de
secret_path (ou sa représentation string appropriée) plutôt que evil_rel afin
d’exercer le cas “fichier hors music_root mais existant” de façon fiable
(référez-vous aux symboles secret_path, evil_rel, setup.library_id et à la
requête INSERT INTO track pour localiser l’endroit à modifier).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f46e39f6-4be1-4dc1-80be-46865b479b5e

📥 Commits

Reviewing files that changed from the base of the PR and between 5b9edfb and 699ff81.

📒 Files selected for processing (2)
  • src/config.rs
  • tests/stream.rs

Comment thread tests/stream.rs
CR follow-up on path_traversal_attempts_404:

The previous version pointed at ../../../etc/passwd, which means
the test passed on hosts where /etc/passwd exists (Ubuntu CI:
yes) via the prefix-check guard, but on a stripped image without
that file it would 404 via the canonicalize-failure path — same
outcome, different code path. The whole point of the test is to
lock in the prefix-check specifically.

Plant the secret file in a sibling tempdir of the music root and
use a ..-laced relative path that, after music_root.join + canonicalize,
resolves onto the existing-but-out-of-root file. Canonicalise
outside_root too so the comparison survives macOS's /tmp ->
/private/tmp symlink.

Lib tests 18/18 still pass, clippy clean, fmt clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL
InstaZDLL merged commit 5c126a3 into main May 31, 2026
8 checks passed
@InstaZDLL
InstaZDLL deleted the feat/1-e-1-stream-server branch May 31, 2026 12:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant