a73x

src/server/http/repo/releases.rs

Ref:   Size: 18.0 KiB   History

use std::sync::Arc;

use axum::extract::{Path, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;

use super::{collab_counts, open_repo, AppState};
use crate::releases::{list_releases, releases_dir, ReleaseVersion};

/// Artifacts can be replaced with `--force`, so we can't tell clients to
/// cache them forever (`immutable` would be a lie). A short max-age plus a
/// strong validator (ETag/Last-Modified) lets caches revalidate cheaply
/// instead of re-downloading on every request.
const RELEASE_CACHE_MAX_AGE_SECS: u64 = 300;

#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "releases.html")]
pub struct ReleasesTemplate {
    pub site_title: String,
    pub repo_name: String,
    pub active_section: String,
    pub open_patches: usize,
    pub open_issues: usize,
    pub versions: Vec<ReleaseVersion>,
    /// Whether the artifact download endpoint is reachable for this repo
    /// (`allows_anonymous_http`). When false, the page still lists files
    /// (the source tree is already public in that configuration) but must
    /// not render links that would just 404.
    pub downloads_available: bool,
}

pub async fn releases(
    Path(repo_name): Path<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    let (entry, repo) = match open_repo(&state, &repo_name) {
        Ok(pair) => pair,
        Err(resp) => return resp,
    };
    let (open_patches, open_issues) = collab_counts(&repo);
    let versions = match list_releases(&releases_dir(&entry)) {
        Ok(index) => index.versions,
        Err(error) => {
            tracing::warn!(
                "failed to list releases for {:?}: {}; showing empty list",
                releases_dir(&entry),
                error
            );
            Vec::new()
        }
    };

    ReleasesTemplate {
        site_title: state.site_title.clone(),
        repo_name,
        active_section: "releases".to_string(),
        open_patches,
        open_issues,
        versions,
        downloads_available: entry.allows_anonymous_http(),
    }
    .into_response()
}

pub async fn release_download(
    Path((repo_name, version, filename)): Path<(String, String, String)>,
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Response {
    let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
        Some(e) => e,
        None => return plain_404(),
    };
    // Downloads are data distribution, like clone.
    if !entry.allows_anonymous_http() {
        return plain_404();
    }

    let path = match crate::releases::artifact_path(&releases_dir(&entry), &version, &filename) {
        Ok(p) => p,
        Err(_) => return plain_404(),
    };

    let mut file = match tokio::fs::File::open(&path).await {
        Ok(f) => f,
        Err(_) => return plain_404(),
    };
    let metadata = match file.metadata().await {
        Ok(m) if m.is_file() => m,
        _ => return plain_404(),
    };
    let len = metadata.len();

    let content_type = if filename.ends_with(".sha256") {
        "text/plain; charset=utf-8"
    } else {
        "application/octet-stream"
    };

    let modified = metadata
        .modified()
        .ok()
        .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
        .unwrap_or_default();
    let modified_secs = modified.as_secs() as i64;
    let etag = compute_etag(len, modified_secs, modified.subsec_nanos());
    let last_modified = format_http_date(modified_secs);

    if is_not_modified(&headers, &etag, modified_secs) {
        let mut response = Response::new(axum::body::Body::empty());
        *response.status_mut() = StatusCode::NOT_MODIFIED;
        set_validator_headers(response.headers_mut(), &etag, &last_modified);
        return response;
    }

    // RFC 7233 §3.2: a `Range` request paired with an `If-Range` validator
    // that no longer matches the current resource must be served as if
    // `Range` were absent. Without this, a client resuming a download after
    // a `--force` replacement would splice bytes from two different
    // artifact versions into one file — silent corruption, not just a wrong
    // status code. `If-Range` genuinely absent is the only case that honors
    // `Range` normally; present-but-unparseable (invalid UTF-8, or a value
    // `if_range_matches` doesn't recognize) fails closed — see its doc
    // comment for why the two failure directions aren't symmetric here.
    let honor_range = match headers.get(header::IF_RANGE) {
        None => true,
        Some(raw) => match raw.to_str() {
            Ok(v) => if_range_matches(v, &etag, modified_secs),
            Err(_) => false,
        },
    };
    let range_header = if honor_range {
        headers.get(header::RANGE)
    } else {
        None
    };

    match parse_range(range_header, len) {
        RangeRequest::Full => {
            let mut response =
                Response::new(axum::body::Body::from_stream(ReaderStream::new(file)));
            *response.status_mut() = StatusCode::OK;
            let h = response.headers_mut();
            h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
            if let Ok(value) = HeaderValue::from_str(&len.to_string()) {
                h.insert(header::CONTENT_LENGTH, value);
            }
            h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
            set_validator_headers(h, &etag, &last_modified);
            response
        }
        RangeRequest::Satisfiable(start, end) => {
            if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
                return plain_404();
            }
            let take_len = end - start + 1;
            let limited = file.take(take_len);
            let mut response =
                Response::new(axum::body::Body::from_stream(ReaderStream::new(limited)));
            *response.status_mut() = StatusCode::PARTIAL_CONTENT;
            let h = response.headers_mut();
            h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
            if let Ok(value) = HeaderValue::from_str(&take_len.to_string()) {
                h.insert(header::CONTENT_LENGTH, value);
            }
            if let Ok(value) = HeaderValue::from_str(&format!("bytes {}-{}/{}", start, end, len)) {
                h.insert(header::CONTENT_RANGE, value);
            }
            h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
            set_validator_headers(h, &etag, &last_modified);
            response
        }
        RangeRequest::Unsatisfiable => {
            let mut response = Response::new(axum::body::Body::empty());
            *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
            let h = response.headers_mut();
            if let Ok(value) = HeaderValue::from_str(&format!("bytes */{}", len)) {
                h.insert(header::CONTENT_RANGE, value);
            }
            h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
            response
        }
    }
}

fn set_validator_headers(headers: &mut HeaderMap, etag: &str, last_modified: &str) {
    if let Ok(value) = HeaderValue::from_str(etag) {
        headers.insert(header::ETAG, value);
    }
    if let Ok(value) = HeaderValue::from_str(last_modified) {
        headers.insert(header::LAST_MODIFIED, value);
    }
    headers.insert(
        header::CACHE_CONTROL,
        HeaderValue::from_str(&format!("public, max-age={}", RELEASE_CACHE_MAX_AGE_SECS))
            .unwrap_or_else(|_| HeaderValue::from_static("public")),
    );
}

/// Build the artifact's ETag from its size and modification time. Nanosecond
/// precision matters: `Last-Modified` (and naive second-only mtimes) can't
/// distinguish two `--force` replacements that land in the same wall-clock
/// second, which would otherwise make a conditional GET return a `304` for
/// genuinely different content. Nanoseconds close that window (though they
/// don't eliminate it on filesystems/clocks with coarser-than-nanosecond
/// resolution).
fn compute_etag(len: u64, modified_secs: i64, modified_nanos: u32) -> String {
    format!("\"{:x}-{:x}-{:x}\"", len, modified_secs, modified_nanos)
}

/// Check `If-None-Match` (preferred) or `If-Modified-Since` against the
/// artifact's current validators. `If-None-Match` wins when both are present,
/// per RFC 7232 §6. `If-None-Match` uses *weak* comparison (RFC 7232 §2.3.2):
/// a client presenting `W/"<our-etag>"` must still get a 304.
fn is_not_modified(headers: &HeaderMap, etag: &str, modified_secs: i64) -> bool {
    if let Some(inm) = headers.get(header::IF_NONE_MATCH) {
        return match inm.to_str() {
            Ok(value) => value.split(',').any(|tag| {
                let tag = tag.trim();
                tag == "*" || weak_strip(tag) == weak_strip(etag)
            }),
            Err(_) => false,
        };
    }
    if let Some(ims) = headers.get(header::IF_MODIFIED_SINCE) {
        if let Ok(value) = ims.to_str() {
            if let Some(since) = parse_http_date(value) {
                return modified_secs <= since;
            }
        }
    }
    false
}

fn weak_strip(tag: &str) -> &str {
    tag.strip_prefix("W/").unwrap_or(tag)
}

/// Whether an `If-Range` header value still matches the artifact's current
/// validators. Accepts either form defined by RFC 7233 §3.2:
/// - An entity-tag, compared *strongly* — a weak tag (`W/"..."`) can never
///   satisfy `If-Range`, even if the underlying value is identical.
/// - An HTTP-date, compared against `Last-Modified` at whole-second
///   resolution. The format has no finer precision, so (unlike our ETag)
///   this form cannot detect a same-second replacement — prefer the ETag
///   form when precision matters.
///
/// A value that is neither of those — an obsolete RFC 850/asctime date we
/// don't parse (yet), a lowercase `w/`, an unquoted tag, or outright
/// garbage — is treated as a **non-match**, forcing a full response rather
/// than honoring `Range`. This is a deliberate fail-closed choice, not
/// something RFC 7233 §3.2 itself mandates: RFC 9110 §5.6.7 requires
/// recipients to accept RFC 850 and asctime dates too, so a client sending
/// one isn't sending malformed input, only a format we don't parse yet — and
/// the two ways of getting this wrong aren't symmetric. Misreading a usable
/// value as unusable costs an unnecessary full re-download; misreading an
/// unusable value as still-fresh risks silently splicing bytes from two
/// different artifact versions into one corrupted file. Parsing the
/// obsolete date formats is a reasonable follow-up; serving a 206 on faith
/// in the meantime is not.
fn if_range_matches(value: &str, etag: &str, modified_secs: i64) -> bool {
    let value = value.trim();
    if value.starts_with("W/") {
        return false;
    }
    if value.starts_with('"') {
        return value == etag;
    }
    match parse_http_date(value) {
        Some(since) => since == modified_secs,
        None => false,
    }
}

/// Format a Unix timestamp as an RFC 7231 IMF-fixdate, e.g.
/// "Sun, 06 Nov 1994 08:49:37 GMT".
fn format_http_date(secs: i64) -> String {
    chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
        .unwrap_or_default()
        .format("%a, %d %b %Y %H:%M:%S GMT")
        .to_string()
}

/// Parse an RFC 7231 IMF-fixdate back into a Unix timestamp. Other obsolete
/// HTTP-date formats are not supported; a request using one simply won't
/// get a 304, which is always spec-legal.
fn parse_http_date(value: &str) -> Option<i64> {
    chrono::NaiveDateTime::parse_from_str(value.trim(), "%a, %d %b %Y %H:%M:%S GMT")
        .ok()
        .map(|naive| naive.and_utc().timestamp())
}

enum RangeRequest {
    /// No usable Range header: serve the whole file. This also covers
    /// malformed and multi-range requests — ignoring the header and serving
    /// a full 200 is always spec-legal (RFC 7233 §3.1).
    Full,
    /// A single valid, in-bounds byte range (inclusive start/end).
    Satisfiable(u64, u64),
    /// A syntactically valid single range that doesn't fit in the file.
    Unsatisfiable,
}

/// Parse a `Range` header for a resource of length `len`. Only single-range
/// `bytes=` requests are handled (`N-M`, `N-`, `-N`); anything else falls
/// back to `Full`.
fn parse_range(header: Option<&HeaderValue>, len: u64) -> RangeRequest {
    let header = match header.and_then(|v| v.to_str().ok()) {
        Some(h) => h.trim(),
        None => return RangeRequest::Full,
    };
    let spec = match header.strip_prefix("bytes=") {
        Some(s) => s,
        None => return RangeRequest::Full,
    };
    // Multi-range requests are legal to ignore; serving the full body is
    // simpler and always spec-compliant.
    if spec.contains(',') {
        return RangeRequest::Full;
    }
    let (start_str, end_str) = match spec.split_once('-') {
        Some(pair) => pair,
        None => return RangeRequest::Full,
    };

    if start_str.is_empty() {
        // Suffix range: "bytes=-N" means the last N bytes.
        let suffix_len: u64 = match end_str.parse() {
            Ok(n) => n,
            Err(_) => return RangeRequest::Full,
        };
        if suffix_len == 0 || len == 0 {
            return RangeRequest::Unsatisfiable;
        }
        let start = len.saturating_sub(suffix_len);
        return RangeRequest::Satisfiable(start, len - 1);
    }

    let start: u64 = match start_str.parse() {
        Ok(n) => n,
        Err(_) => return RangeRequest::Full,
    };
    if start >= len {
        return RangeRequest::Unsatisfiable;
    }
    let end: u64 = if end_str.is_empty() {
        len - 1
    } else {
        match end_str.parse::<u64>() {
            Ok(n) => n.min(len - 1),
            Err(_) => return RangeRequest::Full,
        }
    };
    if end < start {
        return RangeRequest::Full;
    }
    RangeRequest::Satisfiable(start, end)
}

fn plain_404() -> Response {
    (StatusCode::NOT_FOUND, "Not found").into_response()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn etag_is_sensitive_to_subsecond_mtime_changes() {
        // Two `--force` replacements landing in the same wall-clock second
        // must still produce different ETags, or a conditional GET could
        // return a stale 304 for genuinely new content.
        let a = compute_etag(10, 1_700_000_000, 100);
        let b = compute_etag(10, 1_700_000_000, 200);
        assert_ne!(a, b);
    }

    #[test]
    fn if_range_etag_form_detects_subsecond_replacement() {
        // The client presents the ETag it received before a same-second
        // replacement. Because our ETag carries nanosecond precision, this
        // correctly fails to match, so the caller falls back to a full
        // response instead of splicing old and new bytes together.
        let old_etag = compute_etag(10, 1_700_000_000, 100);
        let current_etag = compute_etag(10, 1_700_000_000, 200);
        assert!(!if_range_matches(&old_etag, &current_etag, 1_700_000_000));
    }

    #[test]
    fn if_range_date_form_cannot_detect_subsecond_replacement() {
        // Documented limitation, not a bug: the `Last-Modified`/`If-Range`
        // date form only carries whole-second resolution, so a same-second
        // replacement is invisible to it even though the content (and the
        // ETag) actually changed. This is why `if_range_matches` prefers the
        // ETag form whenever a client sends one — see the doc comment above.
        let same_second_date = format_http_date(1_700_000_000);
        assert!(if_range_matches(
            &same_second_date,
            "\"irrelevant-current-etag\"",
            1_700_000_000
        ));
    }

    #[test]
    fn if_range_weak_etag_never_matches() {
        // RFC 7233 §3.2 requires a strong comparison for If-Range; a weak
        // validator can never satisfy it, even if the tag value is identical.
        assert!(!if_range_matches("W/\"abc\"", "\"abc\"", 0));
    }

    #[test]
    fn if_range_unparsable_value_fails_closed() {
        // Garbage that's neither a recognized entity-tag nor a parseable
        // date must NOT be treated as a match — that would honor `Range`
        // on faith and reopen the splice path. Fail closed: serve a full
        // response instead.
        assert!(!if_range_matches("garbage", "\"abc\"", 0));
    }

    #[test]
    fn if_range_lowercase_weak_prefix_fails_closed() {
        // Only the exact `W/` prefix is recognized as a weak tag; a
        // lowercase `w/` doesn't match that branch and falls through to the
        // entity-tag/date parsing, neither of which accepts it — so it must
        // still fail closed, not be silently treated as a strong tag match.
        assert!(!if_range_matches("w/\"abc\"", "\"abc\"", 0));
    }

    #[test]
    fn if_range_obsolete_date_formats_fail_closed() {
        // RFC 9110 §5.6.7 requires recipients to accept RFC 850 and asctime
        // dates, so these are not malformed input - we just don't parse
        // them yet. Until we do, they must fail closed rather than being
        // silently treated as a match.
        assert!(!if_range_matches(
            "Sunday, 06-Nov-94 08:49:37 GMT", // RFC 850
            "\"abc\"",
            0
        ));
        assert!(!if_range_matches(
            "Sun Nov  6 08:49:37 1994", // asctime
            "\"abc\"",
            0
        ));
    }

    #[test]
    fn if_none_match_weak_comparison_matches_our_strong_etag() {
        let mut headers = HeaderMap::new();
        headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("W/\"abc\""));
        assert!(is_not_modified(&headers, "\"abc\"", 0));
    }

    #[test]
    fn if_none_match_wildcard_matches() {
        let mut headers = HeaderMap::new();
        headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("*"));
        assert!(is_not_modified(&headers, "\"anything\"", 0));
    }

    #[test]
    fn if_none_match_non_matching_tag_is_modified() {
        let mut headers = HeaderMap::new();
        headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"other\""));
        assert!(!is_not_modified(&headers, "\"abc\"", 0));
    }
}