//! Raw user-content origin (separate listener, explicit routes; never `.discover()`). //! See docs/m2-interfaces.md, "Raw origin". use std::path::Path; use std::process::Stdio; use std::time::{SystemTime, UNIX_EPOCH}; use bytes::Bytes; use forge_core::raw_token::{sign_raw, verify_raw}; use forge_core::{Repo, Visibility}; use forge_git::GitCommand; use forge_git::browse::{self, EntryKind}; use futures_util::{StreamExt, TryStreamExt, stream}; use http_body::Frame; use http_body_util::StreamBody; use serde::Deserialize; use tokio::io::AsyncReadExt; use tokio::process::Child; use tokio_util::io::ReaderStream; use topcoat::context::{Cx, app_context}; use topcoat::router::request::{headers, method}; use topcoat::router::response::Response; use topcoat::router::{ Body, Compression, HeaderName, HeaderValue, Method, RouteFn, RouteFuture, Router, StatusCode, header, parse_query_params, path_param_segment, path_param_segments, }; use crate::internal::is_valid_oid; use crate::state::AppState; /// Blobs up to this size are read in full. Larger blobs are streamed from `git cat-file`. const MAX_INLINE_BYTES: u64 = 8 * 1024 * 1024; /// Bytes inspected to decide text versus binary. const SNIFF_BYTES: usize = 8000; /// Lifetime of a signed private raw URL. pub(crate) const LINK_TTL_SECS: i64 = 600; const CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"; const CORP: &str = "cross-origin"; pub(crate) const CACHE_PRIVATE: &str = "private, max-age=300"; pub(crate) const CACHE_PUBLIC: &str = "public, max-age=300"; pub(crate) const CACHE_NONE: &str = "no-store"; const TEXT: &str = "text/plain; charset=utf-8"; pub(crate) const OCTET: &str = "application/octet-stream"; /// Absolute URL of a file on the raw origin. For private repos it carries a signed `?token=` /// valid for 10 minutes. `rev` is the ref name or commit oid as shown in the app URL. pub fn raw_url(state: &AppState, repo: &Repo, rev: &str, path: &str) -> String { let base = state.config.user_content_base_url.trim_end_matches('/'); let mut url = format!( "{base}/{}/{}/{}", encode_path(&repo.owner_name), encode_path(&repo.name), encode_path(rev) ); if !path.is_empty() { url.push('/'); url.push_str(&encode_path(path)); } if matches!(repo.visibility, Visibility::Private) { let expires = now_unix().saturating_add(LINK_TTL_SECS); url.push_str("?token="); url.push_str(&sign_raw(&state.key, &repo.id, expires)); } url } /// Percent-encodes each `/`-separated segment and keeps the separators. pub(crate) fn encode_path(value: &str) -> String { value .split('/') .map(encode_segment) .collect::>() .join("/") } /// Percent-encodes everything except RFC 3986 unreserved characters. fn encode_segment(segment: &str) -> String { let mut out = String::with_capacity(segment.len()); for byte in segment.bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { out.push(char::from(byte)); } else { out.push_str(&format!("%{byte:02X}")); } } out } /// Explicit routes only. No cookie layer: this origin never reads or sets cookies. pub fn router(state: AppState) -> Router { Router::builder() .route(RouteFn::new( vec![Method::GET, Method::HEAD], "/{owner}/{repo}/{*rest}", raw_file, )) // Media is served from the LFS store, so it is routed before the catch-all raw route. // It sits under the literal `-` segment, which no ref can use, so a branch named `media` // still resolves through the raw route. .route(RouteFn::new( vec![Method::GET, Method::HEAD], "/{owner}/{repo}/-/media/{*rest}", crate::pages::media::media_file, )) // Snippet files sit under the same reserved `-` segment: `/-/snippets/{id}/{file}`. .route(RouteFn::new( vec![Method::GET, Method::HEAD], "/-/snippets/{id}/{*rest}", crate::pages::snippet_raw::snippet_file, )) .route(RouteFn::new(Method::GET, "/healthz", healthz)) // Responses must match the bytes behind the ETag and Content-Length. .compression(Compression::off()) .app_context(state) .build() } #[derive(Deserialize)] struct TokenQuery { token: Option, } fn healthz(_cx: &Cx, _body: topcoat::router::Body) -> RouteFuture<'_> { Box::pin(async { let mut res = Response::new(Body::from("ok")); *res.status_mut() = StatusCode::OK; Ok(res) }) } fn raw_file(cx: &Cx, _body: topcoat::router::Body) -> RouteFuture<'_> { Box::pin(async move { Ok(serve(cx).await) }) } /// Serves one file. Every failure is turned into a response here, so the route never errors. async fn serve(cx: &Cx) -> Response { let state = app_context::(cx).clone(); let owner = path_param_segment(cx, "owner").to_owned(); let name = path_param_segment(cx, "repo").to_owned(); let rest = path_param_segments(cx, "rest") .collect::>() .join("/"); // `-` is never a ref name (git and the pre-receive hook reject it). It is reserved for the // `/-/media/` route, so a raw path whose first segment is `-` is a plain 404. if rest.split('/').next() == Some("-") { return plain(StatusCode::NOT_FOUND, CACHE_NONE, None); } let is_head = method(cx) == Method::HEAD; let token = parse_query_params::(cx) .ok() .and_then(|query| query.token); let if_none_match = headers(cx) .get(header::IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) .map(str::to_owned); let repo = match state.store.repo_by_owner_and_name(&owner, &name).await { Ok(Some(repo)) => repo, // A missing repo and a private repo without a valid token look the same. Ok(None) => return plain(StatusCode::NOT_FOUND, CACHE_NONE, None), Err(e) => { tracing::error!(error = %e, "raw: repo lookup failed"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); } }; let cache = match repo.visibility { Visibility::Public => CACHE_PUBLIC, Visibility::Private => { let valid = token .as_deref() .is_some_and(|t| verify_raw(&state.key, t, &repo.id, now_unix())); if !valid { return plain(StatusCode::NOT_FOUND, CACHE_NONE, None); } CACHE_PRIVATE } }; let repo_dir = match forge_git::repo::repo_path(&state.git.data_dir, &repo.id) { Ok(path) => path, Err(e) => { tracing::error!(error = %e, "raw: repo path failed"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); } }; let lookup_dir = repo_dir.clone(); let lookup_rest = rest.clone(); let looked = tokio::task::spawn_blocking(move || { lookup(&lookup_dir, &lookup_rest, if_none_match.as_deref()) }) .await; let looked = match looked { Ok(Ok(found)) => found, Ok(Err(e)) => { tracing::error!(error = %e, "raw: lookup failed"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); } Err(e) => { tracing::error!(error = %e, "raw: lookup task failed"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); } }; let file_name = rest.rsplit('/').next().unwrap_or(""); match looked { Lookup::Missing => plain(StatusCode::NOT_FOUND, CACHE_NONE, None), Lookup::NotModified { oid } => { finish(StatusCode::NOT_MODIFIED, Body::empty(), &oid, cache, None) } Lookup::Inline { oid, symlink, data } => { let (content_type, attachment) = if symlink { (TEXT, false) } else { classify(file_name, &data, true) }; let meta = Meta { content_type, attachment, len: data.len() as u64, }; let body = if is_head { Body::empty() } else { Body::from(Bytes::from(data)) }; finish(StatusCode::OK, body, &oid, cache, Some(meta)) } Lookup::Stream { oid, size } => { let mut child = match spawn_cat_file(&state, &repo_dir, &oid) { Ok(child) => child, Err(e) => { tracing::error!(error = %e, "raw: failed to start git cat-file"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); } }; let Some(mut stdout) = child.stdout.take() else { tracing::error!("raw: git cat-file has no stdout"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); }; let mut sample = Vec::with_capacity(SNIFF_BYTES); if let Err(e) = (&mut stdout) .take(SNIFF_BYTES as u64) .read_to_end(&mut sample) .await { tracing::error!(error = %e, "raw: reading blob sample failed"); return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None); } let (content_type, attachment) = classify(file_name, &sample, false); let meta = Meta { content_type, attachment, len: size, }; if is_head { // Dropping the child kills it: only the headers were needed. drop(child); return finish(StatusCode::OK, Body::empty(), &oid, cache, Some(meta)); } // The child lives inside the closure, so it stays alive until the body is consumed. let chunks = stream::iter([Ok::(Bytes::from(sample))]) .chain(ReaderStream::new(stdout)) .map_ok(Frame::data) .map(move |chunk| { let _ = &child; chunk }); let body = Body::new(StreamBody::new(chunks)); finish(StatusCode::OK, body, &oid, cache, Some(meta)) } } } /// Result of the blocking lookup. pub(crate) enum Lookup { Missing, NotModified { oid: String, }, Inline { oid: String, symlink: bool, data: Vec, }, Stream { oid: String, size: u64, }, } /// Resolves `rest` (`/`) to an entry. Trees, submodules and missing paths are `Missing`. pub(crate) fn lookup( repo_dir: &Path, rest: &str, if_none_match: Option<&str>, ) -> forge_git::Result { let Some((_ref_name, commit, path)) = browse::split_ref_and_path(repo_dir, rest)? else { return Ok(Lookup::Missing); }; if path.is_empty() { return Ok(Lookup::Missing); } let (parent, name) = path.rsplit_once('/').unwrap_or(("", path.as_str())); let Some(entries) = browse::read_tree(repo_dir, &commit, parent)? else { return Ok(Lookup::Missing); }; let Some(entry) = entries.into_iter().find(|e| e.name == name) else { return Ok(Lookup::Missing); }; let symlink = match entry.kind { EntryKind::Blob | EntryKind::Executable => false, EntryKind::Symlink => true, EntryKind::Tree | EntryKind::Submodule => return Ok(Lookup::Missing), }; let oid = entry.oid; let etag = format!("\"{oid}\""); if if_none_match.is_some_and(|value| etag_matches(value, &etag)) { return Ok(Lookup::NotModified { oid }); } if !symlink && let Some(size) = entry.size && size > MAX_INLINE_BYTES { return Ok(Lookup::Stream { oid, size }); } let max_bytes = usize::try_from(MAX_INLINE_BYTES).unwrap_or(usize::MAX); let Some(blob) = browse::read_blob(repo_dir, &commit, &path, max_bytes)? else { return Ok(Lookup::Missing); }; Ok(Lookup::Inline { oid: blob.oid, symlink, data: blob.data, }) } /// `git cat-file blob ` with piped stdout. The OID must be full hex. fn spawn_cat_file(state: &AppState, repo_dir: &Path, oid: &str) -> std::io::Result { if !is_valid_oid(oid) { return Err(std::io::Error::other("invalid object id")); } let mut cmd = GitCommand::new(&state.git, "cat-file") .git_dir(repo_dir) .arg("blob") .user_arg(oid) .map_err(|e| std::io::Error::other(e.to_string()))? .into_tokio(); cmd.stdout(Stdio::piped()).stderr(Stdio::null()); cmd.spawn() } /// Content type and whether the response must be an attachment. fn classify(file_name: &str, sample: &[u8], complete: bool) -> (&'static str, bool) { let ext = file_name .rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) .unwrap_or_default(); if let Some(image) = image_type(&ext) { return (image, false); } let attachment = matches!(ext.as_str(), "svg" | "html" | "htm" | "xhtml" | "xml"); if is_text(sample, complete) { (TEXT, attachment) } else { (OCTET, true) } } fn image_type(ext: &str) -> Option<&'static str> { Some(match ext { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "avif" => "image/avif", "ico" => "image/vnd.microsoft.icon", _ => return None, }) } /// No NUL in the first 8000 bytes and valid UTF-8. For a partial sample (`complete` is false) an /// incomplete sequence at the end is accepted. fn is_text(sample: &[u8], complete: bool) -> bool { let probe = &sample[..sample.len().min(SNIFF_BYTES)]; if probe.contains(&0) { return false; } match std::str::from_utf8(sample) { Ok(_) => true, Err(e) => !complete && e.error_len().is_none(), } } /// True when an `If-None-Match` header lists `etag` (or `*`). Weak prefixes are ignored. fn etag_matches(header_value: &str, etag: &str) -> bool { header_value.split(',').map(str::trim).any(|candidate| { candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag }) } pub(crate) struct Meta { pub(crate) content_type: &'static str, pub(crate) attachment: bool, pub(crate) len: u64, } /// Builds a response with the security and caching headers that every raw response carries. pub(crate) fn finish( status: StatusCode, body: Body, oid: &str, cache: &'static str, meta: Option, ) -> Response { let mut res = Response::new(body); *res.status_mut() = status; let h = res.headers_mut(); h.insert( HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"), ); h.insert( header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(CSP), ); h.insert( HeaderName::from_static("cross-origin-resource-policy"), HeaderValue::from_static(CORP), ); h.insert( header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"), ); h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); if let Ok(etag) = HeaderValue::from_str(&format!("\"{oid}\"")) { h.insert(header::ETAG, etag); } if let Some(meta) = meta { h.insert( header::CONTENT_TYPE, HeaderValue::from_static(meta.content_type), ); h.insert(header::CONTENT_LENGTH, HeaderValue::from(meta.len)); if meta.attachment { h.insert( header::CONTENT_DISPOSITION, HeaderValue::from_static("attachment"), ); } } res } /// Error or empty response with no ETag and a text body. pub(crate) fn plain( status: StatusCode, cache: &'static str, body: Option<&'static str>, ) -> Response { let text = body.unwrap_or(match status { StatusCode::NOT_FOUND => "not found", _ => "internal error", }); let mut res = finish(status, Body::from(text), "", cache, None); res.headers_mut().remove(header::ETAG); res.headers_mut() .insert(header::CONTENT_TYPE, HeaderValue::from_static(TEXT)); res } pub(crate) fn now_unix() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use topcoat::router::Body as RouterBody; use topcoat::router::request::Request; const KEY: [u8; 32] = [7; 32]; struct Fixture { _tmp: tempfile::TempDir, state: AppState, router: Router, public: Repo, private: Repo, } fn git(dir: &Path, args: &[&str]) { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(["-c", "commit.gpgsign=false"]) .args(args) .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .output() .expect("git runs"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); } /// Creates a work tree with `files` and a symlink, then pushes it to `bare` as `main`. fn populate(work: &Path, bare: &Path, files: &[(&str, Vec)]) { std::fs::create_dir_all(work).expect("mkdir work"); git(work, &["init", "-q", "-b", "main"]); for (rel, contents) in files { let path = work.join(rel); std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); std::fs::write(&path, contents).expect("write file"); } std::os::unix::fs::symlink("hello.txt", work.join("link")).expect("symlink"); git(work, &["add", "-A"]); git(work, &["commit", "-q", "-m", "init"]); git(work, &["push", "-q", bare.to_str().expect("utf8"), "main"]); } async fn fixture() -> Fixture { let tmp = tempfile::tempdir().expect("tempdir"); let data_dir = tmp.path().join("data"); std::fs::create_dir_all(&data_dir).expect("data dir"); let data_dir = data_dir.canonicalize().expect("canonical data dir"); let mut config = crate::test_support::test_config(&data_dir); config.user_content_base_url = "http://127.0.0.1:7661".into(); let state = crate::state::AppState { config: std::sync::Arc::new(config), ..crate::test_support::test_state(&data_dir).await }; let store = state.store.clone(); let owner = store .create_user("alice", None, "unused", false) .await .expect("user"); let public = store .create_repo(&owner.id, "pub", "", forge_core::Visibility::Public, "main") .await .expect("public repo"); let private = store .create_repo( &owner.id, "priv", "", forge_core::Visibility::Private, "main", ) .await .expect("private repo"); let git_env = state.git.clone(); for repo in [&public, &private] { let bare = forge_git::repo::repo_path(&data_dir, &repo.id).expect("repo path"); forge_git::repo::init_bare(&git_env, &bare, "main") .await .expect("init bare"); } let big: Vec = b"0123456789abcdef\n" .iter() .cycle() .take(9 * 1024 * 1024) .copied() .collect(); let pub_bare = forge_git::repo::repo_path(&data_dir, &public.id).expect("path"); populate( &tmp.path().join("work-pub"), &pub_bare, &[ ("hello.txt", b"hello raw\n".to_vec()), ( "page.html", b"".to_vec(), ), ( "pic.png", vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0], ), ("sub/a.txt", b"nested\n".to_vec()), ("big.txt", big), ], ); let priv_bare = forge_git::repo::repo_path(&data_dir, &private.id).expect("path"); populate( &tmp.path().join("work-priv"), &priv_bare, &[("hello.txt", b"secret\n".to_vec())], ); let router = router(state.clone()); Fixture { _tmp: tmp, state, router, public, private, } } async fn get( router: &Router, method: &str, uri: &str, if_none_match: Option<&str>, ) -> Response { let mut builder = Request::builder().method(method).uri(uri); if let Some(value) = if_none_match { builder = builder.header("if-none-match", value); } let req = builder.body(RouterBody::empty()).expect("request"); router.handle(req).await } async fn body_bytes(res: Response) -> Vec { topcoat::router::to_bytes(res.into_body(), usize::MAX) .await .expect("body") .to_vec() } fn header_str(res: &Response, name: &str) -> String { res.headers() .get(name) .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_owned() } fn assert_security_headers(res: &Response) { assert_eq!(header_str(res, "x-content-type-options"), "nosniff"); assert!(header_str(res, "content-security-policy").contains("sandbox")); assert_eq!( header_str(res, "cross-origin-resource-policy"), "cross-origin" ); assert_eq!(header_str(res, "referrer-policy"), "no-referrer"); } #[tokio::test] async fn public_text_file_is_plain_and_sandboxed() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/pub/main/hello.txt", None).await; assert_eq!(res.status(), 200); assert_eq!(header_str(&res, "content-type"), TEXT); assert_eq!(header_str(&res, "content-disposition"), ""); assert_eq!(header_str(&res, "cache-control"), CACHE_PUBLIC); assert_security_headers(&res); assert_eq!(body_bytes(res).await, b"hello raw\n"); } #[tokio::test] async fn nested_path_resolves() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/pub/main/sub/a.txt", None).await; assert_eq!(res.status(), 200); assert_eq!(body_bytes(res).await, b"nested\n"); } #[tokio::test] async fn html_is_forced_to_attachment() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/pub/main/page.html", None).await; assert_eq!(res.status(), 200); assert_eq!(header_str(&res, "content-type"), TEXT); assert_eq!(header_str(&res, "content-disposition"), "attachment"); } #[tokio::test] async fn png_is_served_as_image() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/pub/main/pic.png", None).await; assert_eq!(res.status(), 200); assert_eq!(header_str(&res, "content-type"), "image/png"); assert_eq!(header_str(&res, "content-disposition"), ""); } #[tokio::test] async fn symlink_is_served_as_its_target_text() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/pub/main/link", None).await; assert_eq!(res.status(), 200); assert_eq!(header_str(&res, "content-type"), TEXT); assert_eq!(body_bytes(res).await, b"hello.txt"); } #[tokio::test] async fn private_repo_requires_a_valid_token() { let fx = fixture().await; let path = "/alice/priv/main/hello.txt"; let now = now_unix(); let none = get(&fx.router, "GET", path, None).await; assert_eq!(none.status(), 404); assert_eq!(header_str(&none, "cache-control"), CACHE_NONE); let valid = sign_raw(&KEY, &fx.private.id, now + 600); let res = get(&fx.router, "GET", &format!("{path}?token={valid}"), None).await; assert_eq!(res.status(), 200); assert_eq!(header_str(&res, "cache-control"), CACHE_PRIVATE); assert_eq!(body_bytes(res).await, b"secret\n"); let expired = sign_raw(&KEY, &fx.private.id, now - 1); let res = get(&fx.router, "GET", &format!("{path}?token={expired}"), None).await; assert_eq!(res.status(), 404); let other = sign_raw(&KEY, &fx.public.id, now + 600); let res = get(&fx.router, "GET", &format!("{path}?token={other}"), None).await; assert_eq!(res.status(), 404); } #[tokio::test] async fn missing_repo_is_404() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/nope/main/hello.txt", None).await; assert_eq!(res.status(), 404); } #[tokio::test] async fn directory_is_404() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/pub/main/sub", None).await; assert_eq!(res.status(), 404); let root = get(&fx.router, "GET", "/alice/pub/main", None).await; assert_eq!(root.status(), 404); } #[tokio::test] async fn etag_gives_304() { let fx = fixture().await; let first = get(&fx.router, "GET", "/alice/pub/main/hello.txt", None).await; let etag = header_str(&first, "etag"); assert!(etag.starts_with('"') && etag.ends_with('"'), "etag {etag}"); let again = get(&fx.router, "GET", "/alice/pub/main/hello.txt", Some(&etag)).await; assert_eq!(again.status(), 304); assert_eq!(header_str(&again, "etag"), etag); } #[tokio::test] async fn head_has_headers_and_no_body() { let fx = fixture().await; let res = get(&fx.router, "HEAD", "/alice/pub/main/hello.txt", None).await; assert_eq!(res.status(), 200); assert_eq!(header_str(&res, "content-type"), TEXT); assert_eq!(header_str(&res, "content-length"), "10"); assert!(body_bytes(res).await.is_empty()); } #[tokio::test] async fn large_file_streams_intact() { let fx = fixture().await; let res = get(&fx.router, "GET", "/alice/pub/main/big.txt", None).await; assert_eq!(res.status(), 200); assert_eq!(header_str(&res, "content-length"), "9437184"); let expected: Vec = b"0123456789abcdef\n" .iter() .cycle() .take(9 * 1024 * 1024) .copied() .collect(); let body = body_bytes(res).await; assert!(body == expected, "streamed body differs"); } #[tokio::test] async fn app_routes_are_not_on_the_raw_origin() { let fx = fixture().await; for path in ["/login", "/settings/keys", "/alice/pub/tree/main"] { let res = get(&fx.router, "GET", path, None).await; assert_eq!(res.status(), 404, "{path} is reachable"); } let health = get(&fx.router, "GET", "/healthz", None).await; assert_eq!(health.status(), 200); } #[tokio::test] async fn raw_url_encodes_and_signs() { let fx = fixture().await; let url = raw_url(&fx.state, &fx.public, "main", "my file#1.txt"); assert_eq!( url, "http://127.0.0.1:7661/alice/pub/main/my%20file%231.txt" ); let private = raw_url(&fx.state, &fx.private, "feature/x", "a b/c#d.txt"); assert!( private .starts_with("http://127.0.0.1:7661/alice/priv/feature/x/a%20b/c%23d.txt?token="), "{private}" ); let token = private.rsplit_once("?token=").map(|(_, t)| t).unwrap_or(""); assert!(verify_raw(&KEY, token, &fx.private.id, now_unix())); } }