b4ef82c8

Archive
Tree [b4ef82c8] [>]
commit
b4ef82c8c0a617f0d2e5e7af6f1b028b8d8012db
parent
author
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
committer
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-29
changes
10
insertions
142
deletions
94
Stream archive and add tokio-util
MCargo.lock
~checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"~~[[package]]+name = "futures-sink"+version = "0.3.32"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"++[[package]]~name = "futures-task"~version = "0.3.32"~source = "registry+https://github.com/rust-lang/crates.io-index"
~ "syntect",~ "thiserror",~ "tokio",+ "tokio-util",~]~~[[package]]
~source = "registry+https://github.com/rust-lang/crates.io-index"~checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"~dependencies = [+ "bytes",~ "libc",~ "mio",~ "pin-project-lite",+ "signal-hook-registry",~ "socket2",~ "tokio-macros",~ "windows-sys",
~ "proc-macro2",~ "quote",~ "syn",+]++[[package]]+name = "tokio-util"+version = "0.7.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"+dependencies = [+ "bytes",+ "futures-core",+ "futures-sink",+ "pin-project-lite",+ "tokio",~]~~[[package]]
MCargo.toml
~serde = { version = "1.0.228", features = ["derive"] }~syntect = { version = "5.2", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }~thiserror = "2.0.18"-tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros"] }+tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "process", "io-util"] }+tokio-util = { version = "0.7", features = ["io"] }
Msrc/cli.rs
~~use temp_name::theme::Theme;~+/// Command-line arguments parsed via clap at startup.~#[derive(Parser)]~pub struct Args {~    /// Path to the directory containing bare git repositories.
Msrc/config.rs
~~use std::sync::OnceLock;~+/// Package name read from `Cargo.toml` at compile time.~pub const PKG_NAME: &str = env!("CARGO_PKG_NAME");++/// Package version read from `Cargo.toml` at compile time.~pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");~~static HOSTNAME: OnceLock<String> = OnceLock::new();
~    FAVICON.get().map_or(&[], |v| v.as_slice())~}~+/// Sets the hostname shown in breadcrumbs and clone URLs.+/// Silently skips `None`.~pub fn set_hostname(v: Option<String>) {~    if let Some(v) = v {~        let _ = HOSTNAME.set(v);~    }~}~+/// Sets the path prefix used in SSH clone URLs.  Silently skips `None`.~pub fn set_ssh_prefix(v: Option<String>) {~    if let Some(v) = v {~        let _ = SSH_PREFIX.set(v);~    }~}~+/// Sets the URL path prefix for reverse-proxy deployments.+/// Silently skips `None`.~pub fn set_base_url(v: Option<String>) {~    if let Some(v) = v {~        let _ = BASE_URL.set(v);~    }~}~+/// Sets the favicon bytes served at `/favicon.ico`.~pub fn set_favicon(v: Vec<u8>) {~    let _ = FAVICON.set(v);~}
Msrc/error.rs
+//! Application error types and HTTP response rendering.+//!+//! Defines the unified [`Error`] enum used across all handlers and the+//! [`Result`] type alias.  `Error` implements [`IntoResponse`] via askama's+//! `error.html` template.+~use askama::Template;~use axum::http::{StatusCode, header};~use axum::response::{IntoResponse, Response};~use thiserror::Error;~+/// Unified error type for the application.+///+/// Each variant maps to an HTTP status code and a rendered error page.~#[derive(Debug, Error)]~pub enum Error {~    #[error(transparent)]
~    GitCorrupt(String),~}~+/// Convenience alias for `std::result::Result<T, Error>`.~pub type Result<T> = std::result::Result<T, Error>;~~#[derive(Template)]
Msrc/lib.rs
~pub mod routes;~pub mod theme;~-// Re-export so askama Template derives can find them at `crate::`.+// Re-exports for askama Template derives (resolved at `crate::`).~pub use config::favicon;~pub use config::{PKG_NAME, PKG_VERSION, base_url, hostname, ssh_prefix};
Msrc/main.rs
+//! Binary entry point. Parses CLI arguments, initialises global configuration,+//! builds the CSS theme, constructs the axum router, and starts the HTTP server.+~mod cli;~~use clap::Parser;
Msrc/routes.rs
+//! Route handlers, shared types, and router construction.+//!+//! This module defines the [`AppState`] injected into every handler, the+//! [`RepoName`] path extractor (which validates against directory traversal),+//! the [`Breadcrumb`] type used by tree/blame pages, and the [`router`]+//! function that wires all 15 routes together.+~mod archive;~mod blame;~mod branch;
Msrc/theme.rs
~~use clap::ValueEnum;~+/// Base stylesheet — layout, navigation, repo list, tree view, blame.~pub const BASE_CSS: &str = include_str!("../static/base.css");++/// Additional styles for rendered Markdown (README files).~pub const MARKDOWN_CSS: &str = include_str!("../static/markdown.css");~~/// A built-in color theme.  `Auto` and `SolarizedAuto` follow the OS
~}~~impl Theme {+    /// Returns the compiled CSS for this theme variant.~    pub const fn css(&self) -> &'static str {~        match self {~            Self::Auto => include_str!("../static/auto.css"),
Msrc/routes/archive.rs
~//! `txz`, or `zip`) for the given ref.  `tar.xz` and `txz` are produced by~//! piping `git archive --format=tar` through an external `xz` process.~-use std::io::Read;-use std::process::{Command, Stdio};+use std::process::Stdio;~+use axum::body::Body;~use axum::extract::{Path, State};~use axum::http::HeaderValue;~use axum::http::header;~use axum::response::IntoResponse;~use serde::Deserialize;-use tokio::task::spawn_blocking;+use tokio::io::AsyncRead;+use tokio::process::{Child, Command};+use tokio_util::io::ReaderStream;~~use crate::error::{Error, Result};~use crate::git;
~    }~}~-/// Runs `git archive` and returns (stdout, success, stderr).-fn run_archive(repo_path: &str, git_format: &str, ref_: &str) -> (Vec<u8>, bool, String) {-    let output = Command::new("git")+/// Spawns `git archive` and streams its stdout directly into the response body.+fn stream_archive(repo_path: &str, git_format: &str, ref_: &str) -> Result<Body> {+    let mut child = Command::new("git")~        .arg("-C")~        .arg(repo_path)~        .arg("archive")~        .arg(format!("--format={git_format}"))~        .arg(ref_)-        .output();--    match output {-        Ok(o) => (-            o.stdout,-            o.status.success(),-            String::from_utf8_lossy(&o.stderr).into_owned(),-        ),-        Err(e) => (Vec::new(), false, e.to_string()),-    }+        .stdout(Stdio::piped())+        .stderr(Stdio::piped())+        .spawn()+        .map_err(Error::Io)?;++    let stdout = child.stdout.take().expect("stdout configured");+    let stderr = child.stderr.take().expect("stderr configured");++    tokio::spawn(reaper(child, stderr, "git archive"));++    let stream = ReaderStream::new(stdout);+    Ok(Body::from_stream(stream))~}~-/// Pipes `git archive --format=tar` through `xz`, returning (stdout, success, stderr).-fn run_archive_xz(repo_path: &str, ref_: &str) -> (Vec<u8>, bool, String) {-    let mut git = match Command::new("git")+/// Pipes `git archive --format=tar` through `xz` and streams the compressed+/// output.+///+/// Tokio's `ChildStdout` does not implement `Into<Stdio>`, so we cannot use+/// the direct `.stdin(git_stdout)` approach that `std::process::Command`+/// supports.  Instead we relay data between the two processes in a background+/// task via `tokio::io::copy`.+fn stream_archive_xz(repo_path: &str, ref_: &str) -> Result<Body> {+    let mut git = Command::new("git")~        .arg("-C")~        .arg(repo_path)~        .arg("archive")
~        .stdout(Stdio::piped())~        .stderr(Stdio::piped())~        .spawn()-    {-        Ok(c) => c,-        Err(e) => return (Vec::new(), false, format!("spawn git: {e}")),-    };+        .map_err(Error::Io)?;~~    let git_stdout = git.stdout.take().expect("stdout configured");-    let xz = match Command::new("xz")-        .stdin(git_stdout)+    let git_stderr = git.stderr.take().expect("stderr configured");++    let mut xz = Command::new("xz")+        .arg("-c")+        .stdin(Stdio::piped())~        .stdout(Stdio::piped())~        .stderr(Stdio::piped())~        .spawn()-    {-        Ok(c) => c,-        Err(e) => return (Vec::new(), false, format!("spawn xz: {e}")),-    };--    // Wait for xz to finish (consumes all piped git output).-    let xz_output = match xz.wait_with_output() {-        Ok(o) => o,-        Err(e) => return (Vec::new(), false, format!("xz wait: {e}")),-    };--    // Check git's exit status. Report git errors first since xz failures-    // are often a downstream consequence of git producing no output.-    let git_status = match git.wait() {-        Ok(s) => s,-        Err(e) => return (Vec::new(), false, format!("git wait: {e}")),-    };--    if !git_status.success() {-        let msg = git-            .stderr-            .take()-            .and_then(|mut s| {-                let mut buf = String::new();-                s.read_to_string(&mut buf).ok()?;-                Some(buf)-            })-            .unwrap_or_default();-        let msg = if msg.is_empty() {-            "git archive failed".into()-        } else {-            msg-        };-        return (Vec::new(), false, msg);-    }+        .map_err(Error::Io)?;++    let mut xz_stdin = xz.stdin.take().expect("stdin configured");+    let xz_stdout = xz.stdout.take().expect("stdout configured");+    let xz_stderr = xz.stderr.take().expect("stderr configured");++    // Relay git's stdout to xz's stdin in a background task.+    tokio::spawn(async move {+        use tokio::io::AsyncWriteExt;+        if let Err(e) =+            tokio::io::copy(&mut tokio::io::BufReader::new(git_stdout), &mut xz_stdin).await+        {+            eprintln!("pipe git->xz: {e}");+        }+        let _ = xz_stdin.shutdown().await;+        // Reap git (its stdout is fully consumed now).+        reaper(git, git_stderr, "git archive").await;+    });++    tokio::spawn(reaper(xz, xz_stderr, "xz compression"));++    let stream = ReaderStream::new(xz_stdout);+    Ok(Body::from_stream(stream))+}~-    if !xz_output.status.success() {-        let msg = String::from_utf8_lossy(&xz_output.stderr);-        let msg = if msg.is_empty() {-            "xz compression failed".into()-        } else {-            msg.into_owned()-        };-        return (Vec::new(), false, msg);+/// Background task that reads stderr and waits for the child process to exit.+/// Errors are printed to stderr — by this point we have already started+/// streaming the response and cannot change the HTTP status.+async fn reaper(mut child: Child, mut stderr: impl AsyncRead + Unpin, label: &'static str) {+    use tokio::io::AsyncReadExt;+    let mut buf = String::new();+    let _ = stderr.read_to_string(&mut buf).await;+    let ok = child.wait().await.is_ok_and(|s| s.success());+    if !ok || !buf.is_empty() {+        eprintln!("{label} failed: {buf}");~    }--    (xz_output.stdout, true, String::new())~}~~/// Streams a repository archive for the given ref in the requested format.
~    let git_repo = git::open_repo(&state.root, &name)?;~    if git_repo.rev_parse_single(params.ref_.as_str()).is_err() {~        return Err(Error::NotFound(format!("ref not found: {}", params.ref_)));-    }--    let repo_path_str = repo_path.to_string_lossy().into_owned();-    let archive_ref = params.ref_.clone();-    let ref_for_closure = archive_ref.clone();--    let (stdout, success, stderr) = spawn_blocking(move || {-        if cfg.uses_xz {-            run_archive_xz(&repo_path_str, &ref_for_closure)-        } else {-            run_archive(&repo_path_str, cfg.git_format, &ref_for_closure)-        }-    })-    .await-    .map_err(|e| Error::Io(std::io::Error::other(e)))?;--    if !success {-        return Err(Error::GitCorrupt(format!("archive failed: {stderr}")));~    }~~    // Strip refs/heads/ or refs/tags/ prefix for a cleaner filename.-    let short_ref = archive_ref+    let short_ref = params+        .ref_~        .strip_prefix("refs/heads/")-        .or_else(|| archive_ref.strip_prefix("refs/tags/"))-        .unwrap_or(&archive_ref);+        .or_else(|| params.ref_.strip_prefix("refs/tags/"))+        .unwrap_or(&params.ref_);~    let filename = format!("{name}-{short_ref}.{}", cfg.extension);~    let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))~        .map_err(|_| Error::BadRequest("invalid header value".into()))?;~~    let content_type = HeaderValue::from_static(cfg.content_type);++    let repo_path = repo_path.to_string_lossy().into_owned();+    let archive_ref = params.ref_.clone();++    let body = if cfg.uses_xz {+        stream_archive_xz(&repo_path, &archive_ref)?+    } else {+        stream_archive(&repo_path, cfg.git_format, &archive_ref)?+    };~~    Ok((~        [~            (header::CONTENT_TYPE, content_type),~            (header::CONTENT_DISPOSITION, disposition),~        ],-        stdout,+        body,~    ))~}