forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
27.9 KiB
1//! Raw user-content origin (separate listener, explicit routes; never `.discover()`).
2//! See docs/m2-interfaces.md, "Raw origin".
3
4use std::path::Path;
5use std::process::Stdio;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use bytes::Bytes;
9use forge_core::raw_token::{sign_raw, verify_raw};
10use forge_core::{Repo, Visibility};
11use forge_git::GitCommand;
12use forge_git::browse::{self, EntryKind};
13use futures_util::{StreamExt, TryStreamExt, stream};
14use http_body::Frame;
15use http_body_util::StreamBody;
16use serde::Deserialize;
17use tokio::io::AsyncReadExt;
18use tokio::process::Child;
19use tokio_util::io::ReaderStream;
20use topcoat::context::{Cx, app_context};
21use topcoat::router::request::{headers, method};
22use topcoat::router::response::Response;
23use topcoat::router::{
24 Body, Compression, HeaderName, HeaderValue, Method, RouteFn, RouteFuture, Router, StatusCode,
25 header, parse_query_params, path_param_segment, path_param_segments,
26};
27
28use crate::internal::is_valid_oid;
29use crate::state::AppState;
30
31/// Blobs up to this size are read in full. Larger blobs are streamed from `git cat-file`.
32const MAX_INLINE_BYTES: u64 = 8 * 1024 * 1024;
33/// Bytes inspected to decide text versus binary.
34const SNIFF_BYTES: usize = 8000;
35/// Lifetime of a signed private raw URL.
36pub(crate) const LINK_TTL_SECS: i64 = 600;
37
38const CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox";
39const CORP: &str = "cross-origin";
40pub(crate) const CACHE_PRIVATE: &str = "private, max-age=300";
41pub(crate) const CACHE_PUBLIC: &str = "public, max-age=300";
42pub(crate) const CACHE_NONE: &str = "no-store";
43const TEXT: &str = "text/plain; charset=utf-8";
44pub(crate) const OCTET: &str = "application/octet-stream";
45
46/// Absolute URL of a file on the raw origin. For private repos it carries a signed `?token=`
47/// valid for 10 minutes. `rev` is the ref name or commit oid as shown in the app URL.
48pub fn raw_url(state: &AppState, repo: &Repo, rev: &str, path: &str) -> String {
49 let base = state.config.user_content_base_url.trim_end_matches('/');
50 let mut url = format!(
51 "{base}/{}/{}/{}",
52 encode_path(&repo.owner_name),
53 encode_path(&repo.name),
54 encode_path(rev)
55 );
56 if !path.is_empty() {
57 url.push('/');
58 url.push_str(&encode_path(path));
59 }
60 if matches!(repo.visibility, Visibility::Private) {
61 let expires = now_unix().saturating_add(LINK_TTL_SECS);
62 url.push_str("?token=");
63 url.push_str(&sign_raw(&state.key, &repo.id, expires));
64 }
65 url
66}
67
68/// Percent-encodes each `/`-separated segment and keeps the separators.
69pub(crate) fn encode_path(value: &str) -> String {
70 value
71 .split('/')
72 .map(encode_segment)
73 .collect::<Vec<_>>()
74 .join("/")
75}
76
77/// Percent-encodes everything except RFC 3986 unreserved characters.
78fn encode_segment(segment: &str) -> String {
79 let mut out = String::with_capacity(segment.len());
80 for byte in segment.bytes() {
81 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
82 out.push(char::from(byte));
83 } else {
84 out.push_str(&format!("%{byte:02X}"));
85 }
86 }
87 out
88}
89
90/// Explicit routes only. No cookie layer: this origin never reads or sets cookies.
91pub fn router(state: AppState) -> Router {
92 Router::builder()
93 .route(RouteFn::new(
94 vec![Method::GET, Method::HEAD],
95 "/{owner}/{repo}/{*rest}",
96 raw_file,
97 ))
98 // Media is served from the LFS store, so it is routed before the catch-all raw route.
99 // It sits under the literal `-` segment, which no ref can use, so a branch named `media`
100 // still resolves through the raw route.
101 .route(RouteFn::new(
102 vec![Method::GET, Method::HEAD],
103 "/{owner}/{repo}/-/media/{*rest}",
104 crate::pages::media::media_file,
105 ))
106 // Snippet files sit under the same reserved `-` segment: `/-/snippets/{id}/{file}`.
107 .route(RouteFn::new(
108 vec![Method::GET, Method::HEAD],
109 "/-/snippets/{id}/{*rest}",
110 crate::pages::snippet_raw::snippet_file,
111 ))
112 .route(RouteFn::new(Method::GET, "/healthz", healthz))
113 // Responses must match the bytes behind the ETag and Content-Length.
114 .compression(Compression::off())
115 .app_context(state)
116 .build()
117}
118
119#[derive(Deserialize)]
120struct TokenQuery {
121 token: Option<String>,
122}
123
124fn healthz(_cx: &Cx, _body: topcoat::router::Body) -> RouteFuture<'_> {
125 Box::pin(async {
126 let mut res = Response::new(Body::from("ok"));
127 *res.status_mut() = StatusCode::OK;
128 Ok(res)
129 })
130}
131
132fn raw_file(cx: &Cx, _body: topcoat::router::Body) -> RouteFuture<'_> {
133 Box::pin(async move { Ok(serve(cx).await) })
134}
135
136/// Serves one file. Every failure is turned into a response here, so the route never errors.
137async fn serve(cx: &Cx) -> Response {
138 let state = app_context::<AppState>(cx).clone();
139 let owner = path_param_segment(cx, "owner").to_owned();
140 let name = path_param_segment(cx, "repo").to_owned();
141 let rest = path_param_segments(cx, "rest")
142 .collect::<Vec<&str>>()
143 .join("/");
144 // `-` is never a ref name (git and the pre-receive hook reject it). It is reserved for the
145 // `/-/media/` route, so a raw path whose first segment is `-` is a plain 404.
146 if rest.split('/').next() == Some("-") {
147 return plain(StatusCode::NOT_FOUND, CACHE_NONE, None);
148 }
149 let is_head = method(cx) == Method::HEAD;
150 let token = parse_query_params::<TokenQuery>(cx)
151 .ok()
152 .and_then(|query| query.token);
153 let if_none_match = headers(cx)
154 .get(header::IF_NONE_MATCH)
155 .and_then(|v| v.to_str().ok())
156 .map(str::to_owned);
157
158 let repo = match state.store.repo_by_owner_and_name(&owner, &name).await {
159 Ok(Some(repo)) => repo,
160 // A missing repo and a private repo without a valid token look the same.
161 Ok(None) => return plain(StatusCode::NOT_FOUND, CACHE_NONE, None),
162 Err(e) => {
163 tracing::error!(error = %e, "raw: repo lookup failed");
164 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
165 }
166 };
167 let cache = match repo.visibility {
168 Visibility::Public => CACHE_PUBLIC,
169 Visibility::Private => {
170 let valid = token
171 .as_deref()
172 .is_some_and(|t| verify_raw(&state.key, t, &repo.id, now_unix()));
173 if !valid {
174 return plain(StatusCode::NOT_FOUND, CACHE_NONE, None);
175 }
176 CACHE_PRIVATE
177 }
178 };
179
180 let repo_dir = match forge_git::repo::repo_path(&state.git.data_dir, &repo.id) {
181 Ok(path) => path,
182 Err(e) => {
183 tracing::error!(error = %e, "raw: repo path failed");
184 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
185 }
186 };
187
188 let lookup_dir = repo_dir.clone();
189 let lookup_rest = rest.clone();
190 let looked = tokio::task::spawn_blocking(move || {
191 lookup(&lookup_dir, &lookup_rest, if_none_match.as_deref())
192 })
193 .await;
194 let looked = match looked {
195 Ok(Ok(found)) => found,
196 Ok(Err(e)) => {
197 tracing::error!(error = %e, "raw: lookup failed");
198 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
199 }
200 Err(e) => {
201 tracing::error!(error = %e, "raw: lookup task failed");
202 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
203 }
204 };
205
206 let file_name = rest.rsplit('/').next().unwrap_or("");
207 match looked {
208 Lookup::Missing => plain(StatusCode::NOT_FOUND, CACHE_NONE, None),
209 Lookup::NotModified { oid } => {
210 finish(StatusCode::NOT_MODIFIED, Body::empty(), &oid, cache, None)
211 }
212 Lookup::Inline { oid, symlink, data } => {
213 let (content_type, attachment) = if symlink {
214 (TEXT, false)
215 } else {
216 classify(file_name, &data, true)
217 };
218 let meta = Meta {
219 content_type,
220 attachment,
221 len: data.len() as u64,
222 };
223 let body = if is_head {
224 Body::empty()
225 } else {
226 Body::from(Bytes::from(data))
227 };
228 finish(StatusCode::OK, body, &oid, cache, Some(meta))
229 }
230 Lookup::Stream { oid, size } => {
231 let mut child = match spawn_cat_file(&state, &repo_dir, &oid) {
232 Ok(child) => child,
233 Err(e) => {
234 tracing::error!(error = %e, "raw: failed to start git cat-file");
235 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
236 }
237 };
238 let Some(mut stdout) = child.stdout.take() else {
239 tracing::error!("raw: git cat-file has no stdout");
240 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
241 };
242 let mut sample = Vec::with_capacity(SNIFF_BYTES);
243 if let Err(e) = (&mut stdout)
244 .take(SNIFF_BYTES as u64)
245 .read_to_end(&mut sample)
246 .await
247 {
248 tracing::error!(error = %e, "raw: reading blob sample failed");
249 return plain(StatusCode::INTERNAL_SERVER_ERROR, CACHE_NONE, None);
250 }
251 let (content_type, attachment) = classify(file_name, &sample, false);
252 let meta = Meta {
253 content_type,
254 attachment,
255 len: size,
256 };
257 if is_head {
258 // Dropping the child kills it: only the headers were needed.
259 drop(child);
260 return finish(StatusCode::OK, Body::empty(), &oid, cache, Some(meta));
261 }
262 // The child lives inside the closure, so it stays alive until the body is consumed.
263 let chunks = stream::iter([Ok::<Bytes, std::io::Error>(Bytes::from(sample))])
264 .chain(ReaderStream::new(stdout))
265 .map_ok(Frame::data)
266 .map(move |chunk| {
267 let _ = &child;
268 chunk
269 });
270 let body = Body::new(StreamBody::new(chunks));
271 finish(StatusCode::OK, body, &oid, cache, Some(meta))
272 }
273 }
274}
275
276/// Result of the blocking lookup.
277pub(crate) enum Lookup {
278 Missing,
279 NotModified {
280 oid: String,
281 },
282 Inline {
283 oid: String,
284 symlink: bool,
285 data: Vec<u8>,
286 },
287 Stream {
288 oid: String,
289 size: u64,
290 },
291}
292
293/// Resolves `rest` (`<ref>/<path>`) to an entry. Trees, submodules and missing paths are `Missing`.
294pub(crate) fn lookup(
295 repo_dir: &Path,
296 rest: &str,
297 if_none_match: Option<&str>,
298) -> forge_git::Result<Lookup> {
299 let Some((_ref_name, commit, path)) = browse::split_ref_and_path(repo_dir, rest)? else {
300 return Ok(Lookup::Missing);
301 };
302 if path.is_empty() {
303 return Ok(Lookup::Missing);
304 }
305 let (parent, name) = path.rsplit_once('/').unwrap_or(("", path.as_str()));
306 let Some(entries) = browse::read_tree(repo_dir, &commit, parent)? else {
307 return Ok(Lookup::Missing);
308 };
309 let Some(entry) = entries.into_iter().find(|e| e.name == name) else {
310 return Ok(Lookup::Missing);
311 };
312 let symlink = match entry.kind {
313 EntryKind::Blob | EntryKind::Executable => false,
314 EntryKind::Symlink => true,
315 EntryKind::Tree | EntryKind::Submodule => return Ok(Lookup::Missing),
316 };
317 let oid = entry.oid;
318 let etag = format!("\"{oid}\"");
319 if if_none_match.is_some_and(|value| etag_matches(value, &etag)) {
320 return Ok(Lookup::NotModified { oid });
321 }
322 if !symlink
323 && let Some(size) = entry.size
324 && size > MAX_INLINE_BYTES
325 {
326 return Ok(Lookup::Stream { oid, size });
327 }
328 let max_bytes = usize::try_from(MAX_INLINE_BYTES).unwrap_or(usize::MAX);
329 let Some(blob) = browse::read_blob(repo_dir, &commit, &path, max_bytes)? else {
330 return Ok(Lookup::Missing);
331 };
332 Ok(Lookup::Inline {
333 oid: blob.oid,
334 symlink,
335 data: blob.data,
336 })
337}
338
339/// `git cat-file blob <oid>` with piped stdout. The OID must be full hex.
340fn spawn_cat_file(state: &AppState, repo_dir: &Path, oid: &str) -> std::io::Result<Child> {
341 if !is_valid_oid(oid) {
342 return Err(std::io::Error::other("invalid object id"));
343 }
344 let mut cmd = GitCommand::new(&state.git, "cat-file")
345 .git_dir(repo_dir)
346 .arg("blob")
347 .user_arg(oid)
348 .map_err(|e| std::io::Error::other(e.to_string()))?
349 .into_tokio();
350 cmd.stdout(Stdio::piped()).stderr(Stdio::null());
351 cmd.spawn()
352}
353
354/// Content type and whether the response must be an attachment.
355fn classify(file_name: &str, sample: &[u8], complete: bool) -> (&'static str, bool) {
356 let ext = file_name
357 .rsplit_once('.')
358 .map(|(_, ext)| ext.to_ascii_lowercase())
359 .unwrap_or_default();
360 if let Some(image) = image_type(&ext) {
361 return (image, false);
362 }
363 let attachment = matches!(ext.as_str(), "svg" | "html" | "htm" | "xhtml" | "xml");
364 if is_text(sample, complete) {
365 (TEXT, attachment)
366 } else {
367 (OCTET, true)
368 }
369}
370
371fn image_type(ext: &str) -> Option<&'static str> {
372 Some(match ext {
373 "png" => "image/png",
374 "jpg" | "jpeg" => "image/jpeg",
375 "gif" => "image/gif",
376 "webp" => "image/webp",
377 "avif" => "image/avif",
378 "ico" => "image/vnd.microsoft.icon",
379 _ => return None,
380 })
381}
382
383/// No NUL in the first 8000 bytes and valid UTF-8. For a partial sample (`complete` is false) an
384/// incomplete sequence at the end is accepted.
385fn is_text(sample: &[u8], complete: bool) -> bool {
386 let probe = &sample[..sample.len().min(SNIFF_BYTES)];
387 if probe.contains(&0) {
388 return false;
389 }
390 match std::str::from_utf8(sample) {
391 Ok(_) => true,
392 Err(e) => !complete && e.error_len().is_none(),
393 }
394}
395
396/// True when an `If-None-Match` header lists `etag` (or `*`). Weak prefixes are ignored.
397fn etag_matches(header_value: &str, etag: &str) -> bool {
398 header_value.split(',').map(str::trim).any(|candidate| {
399 candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
400 })
401}
402
403pub(crate) struct Meta {
404 pub(crate) content_type: &'static str,
405 pub(crate) attachment: bool,
406 pub(crate) len: u64,
407}
408
409/// Builds a response with the security and caching headers that every raw response carries.
410pub(crate) fn finish(
411 status: StatusCode,
412 body: Body,
413 oid: &str,
414 cache: &'static str,
415 meta: Option<Meta>,
416) -> Response {
417 let mut res = Response::new(body);
418 *res.status_mut() = status;
419 let h = res.headers_mut();
420 h.insert(
421 HeaderName::from_static("x-content-type-options"),
422 HeaderValue::from_static("nosniff"),
423 );
424 h.insert(
425 header::CONTENT_SECURITY_POLICY,
426 HeaderValue::from_static(CSP),
427 );
428 h.insert(
429 HeaderName::from_static("cross-origin-resource-policy"),
430 HeaderValue::from_static(CORP),
431 );
432 h.insert(
433 header::REFERRER_POLICY,
434 HeaderValue::from_static("no-referrer"),
435 );
436 h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
437 if let Ok(etag) = HeaderValue::from_str(&format!("\"{oid}\"")) {
438 h.insert(header::ETAG, etag);
439 }
440 if let Some(meta) = meta {
441 h.insert(
442 header::CONTENT_TYPE,
443 HeaderValue::from_static(meta.content_type),
444 );
445 h.insert(header::CONTENT_LENGTH, HeaderValue::from(meta.len));
446 if meta.attachment {
447 h.insert(
448 header::CONTENT_DISPOSITION,
449 HeaderValue::from_static("attachment"),
450 );
451 }
452 }
453 res
454}
455
456/// Error or empty response with no ETag and a text body.
457pub(crate) fn plain(
458 status: StatusCode,
459 cache: &'static str,
460 body: Option<&'static str>,
461) -> Response {
462 let text = body.unwrap_or(match status {
463 StatusCode::NOT_FOUND => "not found",
464 _ => "internal error",
465 });
466 let mut res = finish(status, Body::from(text), "", cache, None);
467 res.headers_mut().remove(header::ETAG);
468 res.headers_mut()
469 .insert(header::CONTENT_TYPE, HeaderValue::from_static(TEXT));
470 res
471}
472
473pub(crate) fn now_unix() -> i64 {
474 SystemTime::now()
475 .duration_since(UNIX_EPOCH)
476 .ok()
477 .and_then(|d| i64::try_from(d.as_secs()).ok())
478 .unwrap_or(0)
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 use topcoat::router::Body as RouterBody;
486 use topcoat::router::request::Request;
487
488 const KEY: [u8; 32] = [7; 32];
489
490 struct Fixture {
491 _tmp: tempfile::TempDir,
492 state: AppState,
493 router: Router,
494 public: Repo,
495 private: Repo,
496 }
497
498 fn git(dir: &Path, args: &[&str]) {
499 let out = std::process::Command::new("git")
500 .arg("-C")
501 .arg(dir)
502 .args(["-c", "commit.gpgsign=false"])
503 .args(args)
504 .env("GIT_AUTHOR_NAME", "test")
505 .env("GIT_AUTHOR_EMAIL", "test@example.com")
506 .env("GIT_COMMITTER_NAME", "test")
507 .env("GIT_COMMITTER_EMAIL", "test@example.com")
508 .output()
509 .expect("git runs");
510 assert!(
511 out.status.success(),
512 "git {args:?} failed: {}",
513 String::from_utf8_lossy(&out.stderr)
514 );
515 }
516
517 /// Creates a work tree with `files` and a symlink, then pushes it to `bare` as `main`.
518 fn populate(work: &Path, bare: &Path, files: &[(&str, Vec<u8>)]) {
519 std::fs::create_dir_all(work).expect("mkdir work");
520 git(work, &["init", "-q", "-b", "main"]);
521 for (rel, contents) in files {
522 let path = work.join(rel);
523 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
524 std::fs::write(&path, contents).expect("write file");
525 }
526 std::os::unix::fs::symlink("hello.txt", work.join("link")).expect("symlink");
527 git(work, &["add", "-A"]);
528 git(work, &["commit", "-q", "-m", "init"]);
529 git(work, &["push", "-q", bare.to_str().expect("utf8"), "main"]);
530 }
531
532 async fn fixture() -> Fixture {
533 let tmp = tempfile::tempdir().expect("tempdir");
534 let data_dir = tmp.path().join("data");
535 std::fs::create_dir_all(&data_dir).expect("data dir");
536 let data_dir = data_dir.canonicalize().expect("canonical data dir");
537 let mut config = crate::test_support::test_config(&data_dir);
538 config.user_content_base_url = "http://127.0.0.1:7661".into();
539 let state = crate::state::AppState {
540 config: std::sync::Arc::new(config),
541 ..crate::test_support::test_state(&data_dir).await
542 };
543 let store = state.store.clone();
544 let owner = store
545 .create_user("alice", None, "unused", false)
546 .await
547 .expect("user");
548 let public = store
549 .create_repo(&owner.id, "pub", "", forge_core::Visibility::Public, "main")
550 .await
551 .expect("public repo");
552 let private = store
553 .create_repo(
554 &owner.id,
555 "priv",
556 "",
557 forge_core::Visibility::Private,
558 "main",
559 )
560 .await
561 .expect("private repo");
562 let git_env = state.git.clone();
563 for repo in [&public, &private] {
564 let bare = forge_git::repo::repo_path(&data_dir, &repo.id).expect("repo path");
565 forge_git::repo::init_bare(&git_env, &bare, "main")
566 .await
567 .expect("init bare");
568 }
569
570 let big: Vec<u8> = b"0123456789abcdef\n"
571 .iter()
572 .cycle()
573 .take(9 * 1024 * 1024)
574 .copied()
575 .collect();
576 let pub_bare = forge_git::repo::repo_path(&data_dir, &public.id).expect("path");
577 populate(
578 &tmp.path().join("work-pub"),
579 &pub_bare,
580 &[
581 ("hello.txt", b"hello raw\n".to_vec()),
582 (
583 "page.html",
584 b"<html><script>alert(1)</script></html>".to_vec(),
585 ),
586 (
587 "pic.png",
588 vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0],
589 ),
590 ("sub/a.txt", b"nested\n".to_vec()),
591 ("big.txt", big),
592 ],
593 );
594 let priv_bare = forge_git::repo::repo_path(&data_dir, &private.id).expect("path");
595 populate(
596 &tmp.path().join("work-priv"),
597 &priv_bare,
598 &[("hello.txt", b"secret\n".to_vec())],
599 );
600
601 let router = router(state.clone());
602 Fixture {
603 _tmp: tmp,
604 state,
605 router,
606 public,
607 private,
608 }
609 }
610
611 async fn get(
612 router: &Router,
613 method: &str,
614 uri: &str,
615 if_none_match: Option<&str>,
616 ) -> Response {
617 let mut builder = Request::builder().method(method).uri(uri);
618 if let Some(value) = if_none_match {
619 builder = builder.header("if-none-match", value);
620 }
621 let req = builder.body(RouterBody::empty()).expect("request");
622 router.handle(req).await
623 }
624
625 async fn body_bytes(res: Response) -> Vec<u8> {
626 topcoat::router::to_bytes(res.into_body(), usize::MAX)
627 .await
628 .expect("body")
629 .to_vec()
630 }
631
632 fn header_str(res: &Response, name: &str) -> String {
633 res.headers()
634 .get(name)
635 .and_then(|v| v.to_str().ok())
636 .unwrap_or("")
637 .to_owned()
638 }
639
640 fn assert_security_headers(res: &Response) {
641 assert_eq!(header_str(res, "x-content-type-options"), "nosniff");
642 assert!(header_str(res, "content-security-policy").contains("sandbox"));
643 assert_eq!(
644 header_str(res, "cross-origin-resource-policy"),
645 "cross-origin"
646 );
647 assert_eq!(header_str(res, "referrer-policy"), "no-referrer");
648 }
649
650 #[tokio::test]
651 async fn public_text_file_is_plain_and_sandboxed() {
652 let fx = fixture().await;
653 let res = get(&fx.router, "GET", "/alice/pub/main/hello.txt", None).await;
654 assert_eq!(res.status(), 200);
655 assert_eq!(header_str(&res, "content-type"), TEXT);
656 assert_eq!(header_str(&res, "content-disposition"), "");
657 assert_eq!(header_str(&res, "cache-control"), CACHE_PUBLIC);
658 assert_security_headers(&res);
659 assert_eq!(body_bytes(res).await, b"hello raw\n");
660 }
661
662 #[tokio::test]
663 async fn nested_path_resolves() {
664 let fx = fixture().await;
665 let res = get(&fx.router, "GET", "/alice/pub/main/sub/a.txt", None).await;
666 assert_eq!(res.status(), 200);
667 assert_eq!(body_bytes(res).await, b"nested\n");
668 }
669
670 #[tokio::test]
671 async fn html_is_forced_to_attachment() {
672 let fx = fixture().await;
673 let res = get(&fx.router, "GET", "/alice/pub/main/page.html", None).await;
674 assert_eq!(res.status(), 200);
675 assert_eq!(header_str(&res, "content-type"), TEXT);
676 assert_eq!(header_str(&res, "content-disposition"), "attachment");
677 }
678
679 #[tokio::test]
680 async fn png_is_served_as_image() {
681 let fx = fixture().await;
682 let res = get(&fx.router, "GET", "/alice/pub/main/pic.png", None).await;
683 assert_eq!(res.status(), 200);
684 assert_eq!(header_str(&res, "content-type"), "image/png");
685 assert_eq!(header_str(&res, "content-disposition"), "");
686 }
687
688 #[tokio::test]
689 async fn symlink_is_served_as_its_target_text() {
690 let fx = fixture().await;
691 let res = get(&fx.router, "GET", "/alice/pub/main/link", None).await;
692 assert_eq!(res.status(), 200);
693 assert_eq!(header_str(&res, "content-type"), TEXT);
694 assert_eq!(body_bytes(res).await, b"hello.txt");
695 }
696
697 #[tokio::test]
698 async fn private_repo_requires_a_valid_token() {
699 let fx = fixture().await;
700 let path = "/alice/priv/main/hello.txt";
701 let now = now_unix();
702
703 let none = get(&fx.router, "GET", path, None).await;
704 assert_eq!(none.status(), 404);
705 assert_eq!(header_str(&none, "cache-control"), CACHE_NONE);
706
707 let valid = sign_raw(&KEY, &fx.private.id, now + 600);
708 let res = get(&fx.router, "GET", &format!("{path}?token={valid}"), None).await;
709 assert_eq!(res.status(), 200);
710 assert_eq!(header_str(&res, "cache-control"), CACHE_PRIVATE);
711 assert_eq!(body_bytes(res).await, b"secret\n");
712
713 let expired = sign_raw(&KEY, &fx.private.id, now - 1);
714 let res = get(&fx.router, "GET", &format!("{path}?token={expired}"), None).await;
715 assert_eq!(res.status(), 404);
716
717 let other = sign_raw(&KEY, &fx.public.id, now + 600);
718 let res = get(&fx.router, "GET", &format!("{path}?token={other}"), None).await;
719 assert_eq!(res.status(), 404);
720 }
721
722 #[tokio::test]
723 async fn missing_repo_is_404() {
724 let fx = fixture().await;
725 let res = get(&fx.router, "GET", "/alice/nope/main/hello.txt", None).await;
726 assert_eq!(res.status(), 404);
727 }
728
729 #[tokio::test]
730 async fn directory_is_404() {
731 let fx = fixture().await;
732 let res = get(&fx.router, "GET", "/alice/pub/main/sub", None).await;
733 assert_eq!(res.status(), 404);
734 let root = get(&fx.router, "GET", "/alice/pub/main", None).await;
735 assert_eq!(root.status(), 404);
736 }
737
738 #[tokio::test]
739 async fn etag_gives_304() {
740 let fx = fixture().await;
741 let first = get(&fx.router, "GET", "/alice/pub/main/hello.txt", None).await;
742 let etag = header_str(&first, "etag");
743 assert!(etag.starts_with('"') && etag.ends_with('"'), "etag {etag}");
744 let again = get(&fx.router, "GET", "/alice/pub/main/hello.txt", Some(&etag)).await;
745 assert_eq!(again.status(), 304);
746 assert_eq!(header_str(&again, "etag"), etag);
747 }
748
749 #[tokio::test]
750 async fn head_has_headers_and_no_body() {
751 let fx = fixture().await;
752 let res = get(&fx.router, "HEAD", "/alice/pub/main/hello.txt", None).await;
753 assert_eq!(res.status(), 200);
754 assert_eq!(header_str(&res, "content-type"), TEXT);
755 assert_eq!(header_str(&res, "content-length"), "10");
756 assert!(body_bytes(res).await.is_empty());
757 }
758
759 #[tokio::test]
760 async fn large_file_streams_intact() {
761 let fx = fixture().await;
762 let res = get(&fx.router, "GET", "/alice/pub/main/big.txt", None).await;
763 assert_eq!(res.status(), 200);
764 assert_eq!(header_str(&res, "content-length"), "9437184");
765 let expected: Vec<u8> = b"0123456789abcdef\n"
766 .iter()
767 .cycle()
768 .take(9 * 1024 * 1024)
769 .copied()
770 .collect();
771 let body = body_bytes(res).await;
772 assert!(body == expected, "streamed body differs");
773 }
774
775 #[tokio::test]
776 async fn app_routes_are_not_on_the_raw_origin() {
777 let fx = fixture().await;
778 for path in ["/login", "/settings/keys", "/alice/pub/tree/main"] {
779 let res = get(&fx.router, "GET", path, None).await;
780 assert_eq!(res.status(), 404, "{path} is reachable");
781 }
782 let health = get(&fx.router, "GET", "/healthz", None).await;
783 assert_eq!(health.status(), 200);
784 }
785
786 #[tokio::test]
787 async fn raw_url_encodes_and_signs() {
788 let fx = fixture().await;
789 let url = raw_url(&fx.state, &fx.public, "main", "my file#1.txt");
790 assert_eq!(
791 url,
792 "http://127.0.0.1:7661/alice/pub/main/my%20file%231.txt"
793 );
794 let private = raw_url(&fx.state, &fx.private, "feature/x", "a b/c#d.txt");
795 assert!(
796 private
797 .starts_with("http://127.0.0.1:7661/alice/priv/feature/x/a%20b/c%23d.txt?token="),
798 "{private}"
799 );
800 let token = private.rsplit_once("?token=").map(|(_, t)| t).unwrap_or("");
801 assert!(verify_raw(&KEY, token, &fx.private.id, now_unix()));
802 }
803}