0dcd14f5
Support range requests and cache validators on release downloads
a73x 2026-08-08 18:18
Commit message
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,10 @@ pub mod git_http; | |||
| 2 | pub mod repo; | 2 | pub mod repo; |
| 3 | pub mod repo_list; | 3 | pub mod repo_list; |
| 4 | 4 | ||
| 5 | use axum::extract::DefaultBodyLimit; | 5 | use axum::extract::{DefaultBodyLimit, Request}; |
| 6 | use axum::http::{HeaderName, HeaderValue}; | ||
| 7 | use axum::middleware::{self, Next}; | ||
| 8 | use axum::response::Response; | ||
| 6 | use axum::Router; | 9 | use axum::Router; |
| 7 | use std::path::PathBuf; | 10 | use std::path::PathBuf; |
| 8 | use std::sync::Arc; | 11 | use std::sync::Arc; |
| @@ -51,5 +54,19 @@ pub fn router(state: AppState) -> Router { | |||
| 51 | axum::routing::post(git_http::upload_pack) | 54 | axum::routing::post(git_http::upload_pack) |
| 52 | .layer(DefaultBodyLimit::max(git_http::UPLOAD_PACK_BODY_LIMIT)), | 55 | .layer(DefaultBodyLimit::max(git_http::UPLOAD_PACK_BODY_LIMIT)), |
| 53 | ) | 56 | ) |
| 57 | .layer(middleware::from_fn(add_nosniff)) | ||
| 54 | .with_state(shared) | 58 | .with_state(shared) |
| 55 | } | 59 | } |
| 60 | |||
| 61 | /// `X-Content-Type-Options: nosniff` on every response. This is additive — | ||
| 62 | /// it doesn't touch Content-Type or Cache-Control, so it's safe to apply | ||
| 63 | /// router-wide without disturbing the git smart-HTTP routes' existing | ||
| 64 | /// headers (e.g. `Cache-Control: no-cache` in git_http.rs). | ||
| 65 | async fn add_nosniff(request: Request, next: Next) -> Response { | ||
| 66 | let mut response = next.run(request).await; | ||
| 67 | response.headers_mut().insert( | ||
| 68 | HeaderName::from_static("x-content-type-options"), | ||
| 69 | HeaderValue::from_static("nosniff"), | ||
| 70 | ); | ||
| 71 | response | ||
| 72 | } | ||
src/server/http/repo/releases.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,13 +1,20 @@ | |||
| 1 | use std::sync::Arc; | 1 | use std::sync::Arc; |
| 2 | 2 | ||
| 3 | use axum::extract::{Path, State}; | 3 | use axum::extract::{Path, State}; |
| 4 | use axum::http::{header, HeaderValue, StatusCode}; | 4 | use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; |
| 5 | use axum::response::{IntoResponse, Response}; | 5 | use axum::response::{IntoResponse, Response}; |
| 6 | use tokio::io::{AsyncReadExt, AsyncSeekExt}; | ||
| 6 | use tokio_util::io::ReaderStream; | 7 | use tokio_util::io::ReaderStream; |
| 7 | 8 | ||
| 8 | use super::{collab_counts, open_repo, AppState}; | 9 | use super::{collab_counts, open_repo, AppState}; |
| 9 | use crate::releases::{list_releases, releases_dir, ReleaseVersion}; | 10 | use crate::releases::{list_releases, releases_dir, ReleaseVersion}; |
| 10 | 11 | ||
| 12 | /// Artifacts can be replaced with `--force`, so we can't tell clients to | ||
| 13 | /// cache them forever (`immutable` would be a lie). A short max-age plus a | ||
| 14 | /// strong validator (ETag/Last-Modified) lets caches revalidate cheaply | ||
| 15 | /// instead of re-downloading on every request. | ||
| 16 | const RELEASE_CACHE_MAX_AGE_SECS: u64 = 300; | ||
| 17 | |||
| 11 | #[derive(askama::Template, askama_web::WebTemplate)] | 18 | #[derive(askama::Template, askama_web::WebTemplate)] |
| 12 | #[template(path = "releases.html")] | 19 | #[template(path = "releases.html")] |
| 13 | pub struct ReleasesTemplate { | 20 | pub struct ReleasesTemplate { |
| @@ -17,6 +24,11 @@ pub struct ReleasesTemplate { | |||
| 17 | pub open_patches: usize, | 24 | pub open_patches: usize, |
| 18 | pub open_issues: usize, | 25 | pub open_issues: usize, |
| 19 | pub versions: Vec<ReleaseVersion>, | 26 | pub versions: Vec<ReleaseVersion>, |
| 27 | /// Whether the artifact download endpoint is reachable for this repo | ||
| 28 | /// (`allows_anonymous_http`). When false, the page still lists files | ||
| 29 | /// (the source tree is already public in that configuration) but must | ||
| 30 | /// not render links that would just 404. | ||
| 31 | pub downloads_available: bool, | ||
| 20 | } | 32 | } |
| 21 | 33 | ||
| 22 | pub async fn releases( | 34 | pub async fn releases( |
| @@ -47,6 +59,7 @@ pub async fn releases( | |||
| 47 | open_patches, | 59 | open_patches, |
| 48 | open_issues, | 60 | open_issues, |
| 49 | versions, | 61 | versions, |
| 62 | downloads_available: entry.policy.allows_anonymous_http(), | ||
| 50 | } | 63 | } |
| 51 | .into_response() | 64 | .into_response() |
| 52 | } | 65 | } |
| @@ -54,6 +67,7 @@ pub async fn releases( | |||
| 54 | pub async fn release_download( | 67 | pub async fn release_download( |
| 55 | Path((repo_name, version, filename)): Path<(String, String, String)>, | 68 | Path((repo_name, version, filename)): Path<(String, String, String)>, |
| 56 | State(state): State<Arc<AppState>>, | 69 | State(state): State<Arc<AppState>>, |
| 70 | headers: HeaderMap, | ||
| 57 | ) -> Response { | 71 | ) -> Response { |
| 58 | let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { | 72 | let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { |
| 59 | Some(e) => e, | 73 | Some(e) => e, |
| @@ -69,14 +83,15 @@ pub async fn release_download( | |||
| 69 | Err(_) => return plain_404(), | 83 | Err(_) => return plain_404(), |
| 70 | }; | 84 | }; |
| 71 | 85 | ||
| 72 | let file = match tokio::fs::File::open(&path).await { | 86 | let mut file = match tokio::fs::File::open(&path).await { |
| 73 | Ok(f) => f, | 87 | Ok(f) => f, |
| 74 | Err(_) => return plain_404(), | 88 | Err(_) => return plain_404(), |
| 75 | }; | 89 | }; |
| 76 | let len = match file.metadata().await { | 90 | let metadata = match file.metadata().await { |
| 77 | Ok(m) if m.is_file() => m.len(), | 91 | Ok(m) if m.is_file() => m, |
| 78 | _ => return plain_404(), | 92 | _ => return plain_404(), |
| 79 | }; | 93 | }; |
| 94 | let len = metadata.len(); | ||
| 80 | 95 | ||
| 81 | let content_type = if filename.ends_with(".sha256") { | 96 | let content_type = if filename.ends_with(".sha256") { |
| 82 | "text/plain; charset=utf-8" | 97 | "text/plain; charset=utf-8" |
| @@ -84,15 +99,371 @@ pub async fn release_download( | |||
| 84 | "application/octet-stream" | 99 | "application/octet-stream" |
| 85 | }; | 100 | }; |
| 86 | 101 | ||
| 87 | let mut response = Response::new(axum::body::Body::from_stream(ReaderStream::new(file))); | 102 | let modified = metadata |
| 88 | let headers = response.headers_mut(); | 103 | .modified() |
| 89 | headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); | 104 | .ok() |
| 90 | if let Ok(value) = HeaderValue::from_str(&len.to_string()) { | 105 | .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()) |
| 91 | headers.insert(header::CONTENT_LENGTH, value); | 106 | .unwrap_or_default(); |
| 107 | let modified_secs = modified.as_secs() as i64; | ||
| 108 | let etag = compute_etag(len, modified_secs, modified.subsec_nanos()); | ||
| 109 | let last_modified = format_http_date(modified_secs); | ||
| 110 | |||
| 111 | if is_not_modified(&headers, &etag, modified_secs) { | ||
| 112 | let mut response = Response::new(axum::body::Body::empty()); | ||
| 113 | *response.status_mut() = StatusCode::NOT_MODIFIED; | ||
| 114 | set_validator_headers(response.headers_mut(), &etag, &last_modified); | ||
| 115 | return response; | ||
| 92 | } | 116 | } |
| 93 | response | 117 | |
| 118 | // RFC 7233 §3.2: a `Range` request paired with an `If-Range` validator | ||
| 119 | // that no longer matches the current resource must be served as if | ||
| 120 | // `Range` were absent. Without this, a client resuming a download after | ||
| 121 | // a `--force` replacement would splice bytes from two different | ||
| 122 | // artifact versions into one file — silent corruption, not just a wrong | ||
| 123 | // status code. `If-Range` genuinely absent is the only case that honors | ||
| 124 | // `Range` normally; present-but-unparseable (invalid UTF-8, or a value | ||
| 125 | // `if_range_matches` doesn't recognize) fails closed — see its doc | ||
| 126 | // comment for why the two failure directions aren't symmetric here. | ||
| 127 | let honor_range = match headers.get(header::IF_RANGE) { | ||
| 128 | None => true, | ||
| 129 | Some(raw) => match raw.to_str() { | ||
| 130 | Ok(v) => if_range_matches(v, &etag, modified_secs), | ||
| 131 | Err(_) => false, | ||
| 132 | }, | ||
| 133 | }; | ||
| 134 | let range_header = if honor_range { | ||
| 135 | headers.get(header::RANGE) | ||
| 136 | } else { | ||
| 137 | None | ||
| 138 | }; | ||
| 139 | |||
| 140 | match parse_range(range_header, len) { | ||
| 141 | RangeRequest::Full => { | ||
| 142 | let mut response = | ||
| 143 | Response::new(axum::body::Body::from_stream(ReaderStream::new(file))); | ||
| 144 | *response.status_mut() = StatusCode::OK; | ||
| 145 | let h = response.headers_mut(); | ||
| 146 | h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); | ||
| 147 | if let Ok(value) = HeaderValue::from_str(&len.to_string()) { | ||
| 148 | h.insert(header::CONTENT_LENGTH, value); | ||
| 149 | } | ||
| 150 | h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); | ||
| 151 | set_validator_headers(h, &etag, &last_modified); | ||
| 152 | response | ||
| 153 | } | ||
| 154 | RangeRequest::Satisfiable(start, end) => { | ||
| 155 | if file.seek(std::io::SeekFrom::Start(start)).await.is_err() { | ||
| 156 | return plain_404(); | ||
| 157 | } | ||
| 158 | let take_len = end - start + 1; | ||
| 159 | let limited = file.take(take_len); | ||
| 160 | let mut response = | ||
| 161 | Response::new(axum::body::Body::from_stream(ReaderStream::new(limited))); | ||
| 162 | *response.status_mut() = StatusCode::PARTIAL_CONTENT; | ||
| 163 | let h = response.headers_mut(); | ||
| 164 | h.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); | ||
| 165 | if let Ok(value) = HeaderValue::from_str(&take_len.to_string()) { | ||
| 166 | h.insert(header::CONTENT_LENGTH, value); | ||
| 167 | } | ||
| 168 | if let Ok(value) = HeaderValue::from_str(&format!("bytes {}-{}/{}", start, end, len)) { | ||
| 169 | h.insert(header::CONTENT_RANGE, value); | ||
| 170 | } | ||
| 171 | h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); | ||
| 172 | set_validator_headers(h, &etag, &last_modified); | ||
| 173 | response | ||
| 174 | } | ||
| 175 | RangeRequest::Unsatisfiable => { | ||
| 176 | let mut response = Response::new(axum::body::Body::empty()); | ||
| 177 | *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; | ||
| 178 | let h = response.headers_mut(); | ||
| 179 | if let Ok(value) = HeaderValue::from_str(&format!("bytes */{}", len)) { | ||
| 180 | h.insert(header::CONTENT_RANGE, value); | ||
| 181 | } | ||
| 182 | h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); | ||
| 183 | response | ||
| 184 | } | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | fn set_validator_headers(headers: &mut HeaderMap, etag: &str, last_modified: &str) { | ||
| 189 | if let Ok(value) = HeaderValue::from_str(etag) { | ||
| 190 | headers.insert(header::ETAG, value); | ||
| 191 | } | ||
| 192 | if let Ok(value) = HeaderValue::from_str(last_modified) { | ||
| 193 | headers.insert(header::LAST_MODIFIED, value); | ||
| 194 | } | ||
| 195 | headers.insert( | ||
| 196 | header::CACHE_CONTROL, | ||
| 197 | HeaderValue::from_str(&format!("public, max-age={}", RELEASE_CACHE_MAX_AGE_SECS)) | ||
| 198 | .unwrap_or_else(|_| HeaderValue::from_static("public")), | ||
| 199 | ); | ||
| 200 | } | ||
| 201 | |||
| 202 | /// Build the artifact's ETag from its size and modification time. Nanosecond | ||
| 203 | /// precision matters: `Last-Modified` (and naive second-only mtimes) can't | ||
| 204 | /// distinguish two `--force` replacements that land in the same wall-clock | ||
| 205 | /// second, which would otherwise make a conditional GET return a `304` for | ||
| 206 | /// genuinely different content. Nanoseconds close that window (though they | ||
| 207 | /// don't eliminate it on filesystems/clocks with coarser-than-nanosecond | ||
| 208 | /// resolution). | ||
| 209 | fn compute_etag(len: u64, modified_secs: i64, modified_nanos: u32) -> String { | ||
| 210 | format!("\"{:x}-{:x}-{:x}\"", len, modified_secs, modified_nanos) | ||
| 211 | } | ||
| 212 | |||
| 213 | /// Check `If-None-Match` (preferred) or `If-Modified-Since` against the | ||
| 214 | /// artifact's current validators. `If-None-Match` wins when both are present, | ||
| 215 | /// per RFC 7232 §6. `If-None-Match` uses *weak* comparison (RFC 7232 §2.3.2): | ||
| 216 | /// a client presenting `W/"<our-etag>"` must still get a 304. | ||
| 217 | fn is_not_modified(headers: &HeaderMap, etag: &str, modified_secs: i64) -> bool { | ||
| 218 | if let Some(inm) = headers.get(header::IF_NONE_MATCH) { | ||
| 219 | return match inm.to_str() { | ||
| 220 | Ok(value) => value.split(',').any(|tag| { | ||
| 221 | let tag = tag.trim(); | ||
| 222 | tag == "*" || weak_strip(tag) == weak_strip(etag) | ||
| 223 | }), | ||
| 224 | Err(_) => false, | ||
| 225 | }; | ||
| 226 | } | ||
| 227 | if let Some(ims) = headers.get(header::IF_MODIFIED_SINCE) { | ||
| 228 | if let Ok(value) = ims.to_str() { | ||
| 229 | if let Some(since) = parse_http_date(value) { | ||
| 230 | return modified_secs <= since; | ||
| 231 | } | ||
| 232 | } | ||
| 233 | } | ||
| 234 | false | ||
| 235 | } | ||
| 236 | |||
| 237 | fn weak_strip(tag: &str) -> &str { | ||
| 238 | tag.strip_prefix("W/").unwrap_or(tag) | ||
| 239 | } | ||
| 240 | |||
| 241 | /// Whether an `If-Range` header value still matches the artifact's current | ||
| 242 | /// validators. Accepts either form defined by RFC 7233 §3.2: | ||
| 243 | /// - An entity-tag, compared *strongly* — a weak tag (`W/"..."`) can never | ||
| 244 | /// satisfy `If-Range`, even if the underlying value is identical. | ||
| 245 | /// - An HTTP-date, compared against `Last-Modified` at whole-second | ||
| 246 | /// resolution. The format has no finer precision, so (unlike our ETag) | ||
| 247 | /// this form cannot detect a same-second replacement — prefer the ETag | ||
| 248 | /// form when precision matters. | ||
| 249 | /// | ||
| 250 | /// A value that is neither of those — an obsolete RFC 850/asctime date we | ||
| 251 | /// don't parse (yet), a lowercase `w/`, an unquoted tag, or outright | ||
| 252 | /// garbage — is treated as a **non-match**, forcing a full response rather | ||
| 253 | /// than honoring `Range`. This is a deliberate fail-closed choice, not | ||
| 254 | /// something RFC 7233 §3.2 itself mandates: RFC 9110 §5.6.7 requires | ||
| 255 | /// recipients to accept RFC 850 and asctime dates too, so a client sending | ||
| 256 | /// one isn't sending malformed input, only a format we don't parse yet — and | ||
| 257 | /// the two ways of getting this wrong aren't symmetric. Misreading a usable | ||
| 258 | /// value as unusable costs an unnecessary full re-download; misreading an | ||
| 259 | /// unusable value as still-fresh risks silently splicing bytes from two | ||
| 260 | /// different artifact versions into one corrupted file. Parsing the | ||
| 261 | /// obsolete date formats is a reasonable follow-up; serving a 206 on faith | ||
| 262 | /// in the meantime is not. | ||
| 263 | fn if_range_matches(value: &str, etag: &str, modified_secs: i64) -> bool { | ||
| 264 | let value = value.trim(); | ||
| 265 | if value.starts_with("W/") { | ||
| 266 | return false; | ||
| 267 | } | ||
| 268 | if value.starts_with('"') { | ||
| 269 | return value == etag; | ||
| 270 | } | ||
| 271 | match parse_http_date(value) { | ||
| 272 | Some(since) => since == modified_secs, | ||
| 273 | None => false, | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | /// Format a Unix timestamp as an RFC 7231 IMF-fixdate, e.g. | ||
| 278 | /// "Sun, 06 Nov 1994 08:49:37 GMT". | ||
| 279 | fn format_http_date(secs: i64) -> String { | ||
| 280 | chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0) | ||
| 281 | .unwrap_or_default() | ||
| 282 | .format("%a, %d %b %Y %H:%M:%S GMT") | ||
| 283 | .to_string() | ||
| 284 | } | ||
| 285 | |||
| 286 | /// Parse an RFC 7231 IMF-fixdate back into a Unix timestamp. Other obsolete | ||
| 287 | /// HTTP-date formats are not supported; a request using one simply won't | ||
| 288 | /// get a 304, which is always spec-legal. | ||
| 289 | fn parse_http_date(value: &str) -> Option<i64> { | ||
| 290 | chrono::NaiveDateTime::parse_from_str(value.trim(), "%a, %d %b %Y %H:%M:%S GMT") | ||
| 291 | .ok() | ||
| 292 | .map(|naive| naive.and_utc().timestamp()) | ||
| 293 | } | ||
| 294 | |||
| 295 | enum RangeRequest { | ||
| 296 | /// No usable Range header: serve the whole file. This also covers | ||
| 297 | /// malformed and multi-range requests — ignoring the header and serving | ||
| 298 | /// a full 200 is always spec-legal (RFC 7233 §3.1). | ||
| 299 | Full, | ||
| 300 | /// A single valid, in-bounds byte range (inclusive start/end). | ||
| 301 | Satisfiable(u64, u64), | ||
| 302 | /// A syntactically valid single range that doesn't fit in the file. | ||
| 303 | Unsatisfiable, | ||
| 304 | } | ||
| 305 | |||
| 306 | /// Parse a `Range` header for a resource of length `len`. Only single-range | ||
| 307 | /// `bytes=` requests are handled (`N-M`, `N-`, `-N`); anything else falls | ||
| 308 | /// back to `Full`. | ||
| 309 | fn parse_range(header: Option<&HeaderValue>, len: u64) -> RangeRequest { | ||
| 310 | let header = match header.and_then(|v| v.to_str().ok()) { | ||
| 311 | Some(h) => h.trim(), | ||
| 312 | None => return RangeRequest::Full, | ||
| 313 | }; | ||
| 314 | let spec = match header.strip_prefix("bytes=") { | ||
| 315 | Some(s) => s, | ||
| 316 | None => return RangeRequest::Full, | ||
| 317 | }; | ||
| 318 | // Multi-range requests are legal to ignore; serving the full body is | ||
| 319 | // simpler and always spec-compliant. | ||
| 320 | if spec.contains(',') { | ||
| 321 | return RangeRequest::Full; | ||
| 322 | } | ||
| 323 | let (start_str, end_str) = match spec.split_once('-') { | ||
| 324 | Some(pair) => pair, | ||
| 325 | None => return RangeRequest::Full, | ||
| 326 | }; | ||
| 327 | |||
| 328 | if start_str.is_empty() { | ||
| 329 | // Suffix range: "bytes=-N" means the last N bytes. | ||
| 330 | let suffix_len: u64 = match end_str.parse() { | ||
| 331 | Ok(n) => n, | ||
| 332 | Err(_) => return RangeRequest::Full, | ||
| 333 | }; | ||
| 334 | if suffix_len == 0 || len == 0 { | ||
| 335 | return RangeRequest::Unsatisfiable; | ||
| 336 | } | ||
| 337 | let start = len.saturating_sub(suffix_len); | ||
| 338 | return RangeRequest::Satisfiable(start, len - 1); | ||
| 339 | } | ||
| 340 | |||
| 341 | let start: u64 = match start_str.parse() { | ||
| 342 | Ok(n) => n, | ||
| 343 | Err(_) => return RangeRequest::Full, | ||
| 344 | }; | ||
| 345 | if start >= len { | ||
| 346 | return RangeRequest::Unsatisfiable; | ||
| 347 | } | ||
| 348 | let end: u64 = if end_str.is_empty() { | ||
| 349 | len - 1 | ||
| 350 | } else { | ||
| 351 | match end_str.parse::<u64>() { | ||
| 352 | Ok(n) => n.min(len - 1), | ||
| 353 | Err(_) => return RangeRequest::Full, | ||
| 354 | } | ||
| 355 | }; | ||
| 356 | if end < start { | ||
| 357 | return RangeRequest::Full; | ||
| 358 | } | ||
| 359 | RangeRequest::Satisfiable(start, end) | ||
| 94 | } | 360 | } |
| 95 | 361 | ||
| 96 | fn plain_404() -> Response { | 362 | fn plain_404() -> Response { |
| 97 | (StatusCode::NOT_FOUND, "Not found").into_response() | 363 | (StatusCode::NOT_FOUND, "Not found").into_response() |
| 98 | } | 364 | } |
| 365 | |||
| 366 | #[cfg(test)] | ||
| 367 | mod tests { | ||
| 368 | use super::*; | ||
| 369 | |||
| 370 | #[test] | ||
| 371 | fn etag_is_sensitive_to_subsecond_mtime_changes() { | ||
| 372 | // Two `--force` replacements landing in the same wall-clock second | ||
| 373 | // must still produce different ETags, or a conditional GET could | ||
| 374 | // return a stale 304 for genuinely new content. | ||
| 375 | let a = compute_etag(10, 1_700_000_000, 100); | ||
| 376 | let b = compute_etag(10, 1_700_000_000, 200); | ||
| 377 | assert_ne!(a, b); | ||
| 378 | } | ||
| 379 | |||
| 380 | #[test] | ||
| 381 | fn if_range_etag_form_detects_subsecond_replacement() { | ||
| 382 | // The client presents the ETag it received before a same-second | ||
| 383 | // replacement. Because our ETag carries nanosecond precision, this | ||
| 384 | // correctly fails to match, so the caller falls back to a full | ||
| 385 | // response instead of splicing old and new bytes together. | ||
| 386 | let old_etag = compute_etag(10, 1_700_000_000, 100); | ||
| 387 | let current_etag = compute_etag(10, 1_700_000_000, 200); | ||
| 388 | assert!(!if_range_matches(&old_etag, ¤t_etag, 1_700_000_000)); | ||
| 389 | } | ||
| 390 | |||
| 391 | #[test] | ||
| 392 | fn if_range_date_form_cannot_detect_subsecond_replacement() { | ||
| 393 | // Documented limitation, not a bug: the `Last-Modified`/`If-Range` | ||
| 394 | // date form only carries whole-second resolution, so a same-second | ||
| 395 | // replacement is invisible to it even though the content (and the | ||
| 396 | // ETag) actually changed. This is why `if_range_matches` prefers the | ||
| 397 | // ETag form whenever a client sends one — see the doc comment above. | ||
| 398 | let same_second_date = format_http_date(1_700_000_000); | ||
| 399 | assert!(if_range_matches( | ||
| 400 | &same_second_date, | ||
| 401 | "\"irrelevant-current-etag\"", | ||
| 402 | 1_700_000_000 | ||
| 403 | )); | ||
| 404 | } | ||
| 405 | |||
| 406 | #[test] | ||
| 407 | fn if_range_weak_etag_never_matches() { | ||
| 408 | // RFC 7233 §3.2 requires a strong comparison for If-Range; a weak | ||
| 409 | // validator can never satisfy it, even if the tag value is identical. | ||
| 410 | assert!(!if_range_matches("W/\"abc\"", "\"abc\"", 0)); | ||
| 411 | } | ||
| 412 | |||
| 413 | #[test] | ||
| 414 | fn if_range_unparsable_value_fails_closed() { | ||
| 415 | // Garbage that's neither a recognized entity-tag nor a parseable | ||
| 416 | // date must NOT be treated as a match — that would honor `Range` | ||
| 417 | // on faith and reopen the splice path. Fail closed: serve a full | ||
| 418 | // response instead. | ||
| 419 | assert!(!if_range_matches("garbage", "\"abc\"", 0)); | ||
| 420 | } | ||
| 421 | |||
| 422 | #[test] | ||
| 423 | fn if_range_lowercase_weak_prefix_fails_closed() { | ||
| 424 | // Only the exact `W/` prefix is recognized as a weak tag; a | ||
| 425 | // lowercase `w/` doesn't match that branch and falls through to the | ||
| 426 | // entity-tag/date parsing, neither of which accepts it — so it must | ||
| 427 | // still fail closed, not be silently treated as a strong tag match. | ||
| 428 | assert!(!if_range_matches("w/\"abc\"", "\"abc\"", 0)); | ||
| 429 | } | ||
| 430 | |||
| 431 | #[test] | ||
| 432 | fn if_range_obsolete_date_formats_fail_closed() { | ||
| 433 | // RFC 9110 §5.6.7 requires recipients to accept RFC 850 and asctime | ||
| 434 | // dates, so these are not malformed input - we just don't parse | ||
| 435 | // them yet. Until we do, they must fail closed rather than being | ||
| 436 | // silently treated as a match. | ||
| 437 | assert!(!if_range_matches( | ||
| 438 | "Sunday, 06-Nov-94 08:49:37 GMT", // RFC 850 | ||
| 439 | "\"abc\"", | ||
| 440 | 0 | ||
| 441 | )); | ||
| 442 | assert!(!if_range_matches( | ||
| 443 | "Sun Nov 6 08:49:37 1994", // asctime | ||
| 444 | "\"abc\"", | ||
| 445 | 0 | ||
| 446 | )); | ||
| 447 | } | ||
| 448 | |||
| 449 | #[test] | ||
| 450 | fn if_none_match_weak_comparison_matches_our_strong_etag() { | ||
| 451 | let mut headers = HeaderMap::new(); | ||
| 452 | headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("W/\"abc\"")); | ||
| 453 | assert!(is_not_modified(&headers, "\"abc\"", 0)); | ||
| 454 | } | ||
| 455 | |||
| 456 | #[test] | ||
| 457 | fn if_none_match_wildcard_matches() { | ||
| 458 | let mut headers = HeaderMap::new(); | ||
| 459 | headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("*")); | ||
| 460 | assert!(is_not_modified(&headers, "\"anything\"", 0)); | ||
| 461 | } | ||
| 462 | |||
| 463 | #[test] | ||
| 464 | fn if_none_match_non_matching_tag_is_modified() { | ||
| 465 | let mut headers = HeaderMap::new(); | ||
| 466 | headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"other\"")); | ||
| 467 | assert!(!is_not_modified(&headers, "\"abc\"", 0)); | ||
| 468 | } | ||
| 469 | } | ||
src/server/http/templates/releases.html
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,9 @@ | |||
| 4 | 4 | ||
| 5 | {% block content %} | 5 | {% block content %} |
| 6 | <h2>Releases</h2> | 6 | <h2>Releases</h2> |
| 7 | {% if !downloads_available %} | ||
| 8 | <p style="color: #666;">Downloads are not publicly available for this repository.</p> | ||
| 9 | {% endif %} | ||
| 7 | {% if versions.is_empty() %} | 10 | {% if versions.is_empty() %} |
| 8 | <p style="color: #666;">No releases.</p> | 11 | <p style="color: #666;">No releases.</p> |
| 9 | {% else %} | 12 | {% else %} |
| @@ -14,7 +17,11 @@ | |||
| 14 | <ul> | 17 | <ul> |
| 15 | {% for f in v.files %} | 18 | {% for f in v.files %} |
| 16 | <li> | 19 | <li> |
| 20 | {% if downloads_available %} | ||
| 17 | <a href="/{{ repo_name }}/releases/{{ v.version }}/{{ f.name }}">{{ f.name }}</a> | 21 | <a href="/{{ repo_name }}/releases/{{ v.version }}/{{ f.name }}">{{ f.name }}</a> |
| 22 | {% else %} | ||
| 23 | {{ f.name }} | ||
| 24 | {% endif %} | ||
| 18 | ({{ f.size }} bytes) | 25 | ({{ f.size }} bytes) |
| 19 | <code>{{ f.sha256 }}</code> | 26 | <code>{{ f.sha256 }}</code> |
| 20 | </li> | 27 | </li> |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -796,21 +796,28 @@ impl ServerHarness { | |||
| 796 | 796 | ||
| 797 | /// Like `get`, but returns the raw body bytes and full header block. | 797 | /// Like `get`, but returns the raw body bytes and full header block. |
| 798 | pub fn get_bytes(&self, path: &str) -> (String, Vec<u8>) { | 798 | pub fn get_bytes(&self, path: &str) -> (String, Vec<u8>) { |
| 799 | self.get_with_headers(path, &[]) | ||
| 800 | } | ||
| 801 | |||
| 802 | /// Like `get_bytes`, but allows sending arbitrary extra request headers | ||
| 803 | /// (e.g. `Range`, `If-None-Match`). Returns the raw header block and body | ||
| 804 | /// bytes, binary-safe. | ||
| 805 | pub fn get_with_headers(&self, path: &str, headers: &[(&str, &str)]) -> (String, Vec<u8>) { | ||
| 799 | let mut stream = TcpStream::connect(self.http_addr).unwrap_or_else(|e| { | 806 | let mut stream = TcpStream::connect(self.http_addr).unwrap_or_else(|e| { |
| 800 | panic!( | 807 | panic!( |
| 801 | "failed to connect to http server on {}: {}", | 808 | "failed to connect to http server on {}: {}", |
| 802 | self.http_addr, e | 809 | self.http_addr, e |
| 803 | ) | 810 | ) |
| 804 | }); | 811 | }); |
| 805 | stream | 812 | let mut request = format!( |
| 806 | .write_all( | 813 | "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n", |
| 807 | format!( | 814 | path, self.http_addr |
| 808 | "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", | 815 | ); |
| 809 | path, self.http_addr | 816 | for (name, value) in headers { |
| 810 | ) | 817 | request.push_str(&format!("{}: {}\r\n", name, value)); |
| 811 | .as_bytes(), | 818 | } |
| 812 | ) | 819 | request.push_str("\r\n"); |
| 813 | .unwrap(); | 820 | stream.write_all(request.as_bytes()).unwrap(); |
| 814 | let mut raw = Vec::new(); | 821 | let mut raw = Vec::new(); |
| 815 | stream.read_to_end(&mut raw).unwrap(); | 822 | stream.read_to_end(&mut raw).unwrap(); |
| 816 | let split = raw | 823 | let split = raw |
tests/release_server_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -37,6 +37,36 @@ fn stdout(output: &Output) -> String { | |||
| 37 | String::from_utf8_lossy(&output.stdout).to_string() | 37 | String::from_utf8_lossy(&output.stdout).to_string() |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | /// Pull a single header value out of a raw response head block, e.g. | ||
| 41 | /// `extract_header(&head, "etag")`. Case-insensitive on the header name. | ||
| 42 | fn extract_header(head: &str, name: &str) -> String { | ||
| 43 | let prefix = format!("{}:", name.to_lowercase()); | ||
| 44 | head.lines() | ||
| 45 | .find(|l| l.to_lowercase().starts_with(&prefix)) | ||
| 46 | .unwrap_or_else(|| panic!("missing header {} in:\n{}", name, head)) | ||
| 47 | .split_once(':') | ||
| 48 | .unwrap() | ||
| 49 | .1 | ||
| 50 | .trim() | ||
| 51 | .to_string() | ||
| 52 | } | ||
| 53 | |||
| 54 | /// Every header line except `Date` (which legitimately varies request to | ||
| 55 | /// request), for comparing that two responses are otherwise byte-identical. | ||
| 56 | fn strip_date_header(head: &str) -> String { | ||
| 57 | head.lines() | ||
| 58 | .filter(|l| !l.to_lowercase().starts_with("date:")) | ||
| 59 | .collect::<Vec<_>>() | ||
| 60 | .join("\n") | ||
| 61 | } | ||
| 62 | |||
| 63 | /// The exact HTTP status line (e.g. `"HTTP/1.1 200 OK"`), so a caller can't | ||
| 64 | /// be fooled by another header happening to contain the same digits as the | ||
| 65 | /// status code it's checking for. | ||
| 66 | fn status_line(head: &str) -> &str { | ||
| 67 | head.lines().next().unwrap_or("") | ||
| 68 | } | ||
| 69 | |||
| 40 | fn assert_ssh_error(output: &Output, needle: &str) { | 70 | fn assert_ssh_error(output: &Output, needle: &str) { |
| 41 | assert!(!output.status.success(), "expected failure, got success"); | 71 | assert!(!output.status.success(), "expected failure, got success"); |
| 42 | let all = format!( | 72 | let all = format!( |
| @@ -405,3 +435,548 @@ fn http_release_page_and_download_gates_are_independent() { | |||
| 405 | head | 435 | head |
| 406 | ); | 436 | ); |
| 407 | } | 437 | } |
| 438 | |||
| 439 | #[test] | ||
| 440 | fn http_release_range_request_returns_partial_content() { | ||
| 441 | let harness = ServerHarness::new("release-range"); | ||
| 442 | harness.push_head(); | ||
| 443 | let content: Vec<u8> = (0u16..=255).map(|b| b as u8).collect(); // 256 deterministic bytes | ||
| 444 | harness.ssh_exec_with_stdin( | ||
| 445 | "collab-release upload 'release-range.git' 'v1' 'app.bin'", | ||
| 446 | &content, | ||
| 447 | ); | ||
| 448 | |||
| 449 | let (head, body) = harness.get_with_headers( | ||
| 450 | "/release-range/releases/v1/app.bin", | ||
| 451 | &[("Range", "bytes=10-19")], | ||
| 452 | ); | ||
| 453 | assert!(head.contains("206"), "expected 206, got: {}", head); | ||
| 454 | assert!( | ||
| 455 | head.to_lowercase() | ||
| 456 | .contains("content-range: bytes 10-19/256"), | ||
| 457 | "missing/wrong content-range: {}", | ||
| 458 | head | ||
| 459 | ); | ||
| 460 | assert!( | ||
| 461 | head.to_lowercase().contains("content-length: 10"), | ||
| 462 | "wrong content-length: {}", | ||
| 463 | head | ||
| 464 | ); | ||
| 465 | assert_eq!(body, content[10..20]); | ||
| 466 | } | ||
| 467 | |||
| 468 | #[test] | ||
| 469 | fn http_release_range_suffix_request() { | ||
| 470 | let harness = ServerHarness::new("release-range-suffix"); | ||
| 471 | harness.push_head(); | ||
| 472 | let content: Vec<u8> = (0u16..=255).map(|b| b as u8).collect(); | ||
| 473 | harness.ssh_exec_with_stdin( | ||
| 474 | "collab-release upload 'release-range-suffix.git' 'v1' 'app.bin'", | ||
| 475 | &content, | ||
| 476 | ); | ||
| 477 | |||
| 478 | let (head, body) = harness.get_with_headers( | ||
| 479 | "/release-range-suffix/releases/v1/app.bin", | ||
| 480 | &[("Range", "bytes=-10")], | ||
| 481 | ); | ||
| 482 | assert!(head.contains("206"), "expected 206, got: {}", head); | ||
| 483 | assert!( | ||
| 484 | head.to_lowercase() | ||
| 485 | .contains("content-range: bytes 246-255/256"), | ||
| 486 | "missing/wrong content-range: {}", | ||
| 487 | head | ||
| 488 | ); | ||
| 489 | assert_eq!(body, content[246..256]); | ||
| 490 | } | ||
| 491 | |||
| 492 | #[test] | ||
| 493 | fn http_release_range_open_ended_request() { | ||
| 494 | let harness = ServerHarness::new("release-range-open"); | ||
| 495 | harness.push_head(); | ||
| 496 | let content: Vec<u8> = (0u16..=255).map(|b| b as u8).collect(); | ||
| 497 | harness.ssh_exec_with_stdin( | ||
| 498 | "collab-release upload 'release-range-open.git' 'v1' 'app.bin'", | ||
| 499 | &content, | ||
| 500 | ); | ||
| 501 | |||
| 502 | let (head, body) = harness.get_with_headers( | ||
| 503 | "/release-range-open/releases/v1/app.bin", | ||
| 504 | &[("Range", "bytes=250-")], | ||
| 505 | ); | ||
| 506 | assert!(head.contains("206"), "expected 206, got: {}", head); | ||
| 507 | assert!( | ||
| 508 | head.to_lowercase() | ||
| 509 | .contains("content-range: bytes 250-255/256"), | ||
| 510 | "missing/wrong content-range: {}", | ||
| 511 | head | ||
| 512 | ); | ||
| 513 | assert_eq!(body, content[250..256]); | ||
| 514 | } | ||
| 515 | |||
| 516 | #[test] | ||
| 517 | fn http_release_range_out_of_range_returns_416() { | ||
| 518 | let harness = ServerHarness::new("release-range-416"); | ||
| 519 | harness.push_head(); | ||
| 520 | let content: Vec<u8> = (0u16..=255).map(|b| b as u8).collect(); | ||
| 521 | harness.ssh_exec_with_stdin( | ||
| 522 | "collab-release upload 'release-range-416.git' 'v1' 'app.bin'", | ||
| 523 | &content, | ||
| 524 | ); | ||
| 525 | |||
| 526 | let (head, _) = harness.get_with_headers( | ||
| 527 | "/release-range-416/releases/v1/app.bin", | ||
| 528 | &[("Range", "bytes=1000-2000")], | ||
| 529 | ); | ||
| 530 | assert!(head.contains("416"), "expected 416, got: {}", head); | ||
| 531 | assert!( | ||
| 532 | head.to_lowercase().contains("content-range: bytes */256"), | ||
| 533 | "missing/wrong content-range: {}", | ||
| 534 | head | ||
| 535 | ); | ||
| 536 | } | ||
| 537 | |||
| 538 | #[test] | ||
| 539 | fn http_release_range_malformed_or_multi_falls_back_to_full_response() { | ||
| 540 | let harness = ServerHarness::new("release-range-bad"); | ||
| 541 | harness.push_head(); | ||
| 542 | let content: Vec<u8> = (0u16..=255).map(|b| b as u8).collect(); | ||
| 543 | harness.ssh_exec_with_stdin( | ||
| 544 | "collab-release upload 'release-range-bad.git' 'v1' 'app.bin'", | ||
| 545 | &content, | ||
| 546 | ); | ||
| 547 | |||
| 548 | // Multi-range: ignore the header, serve the full 200 (always spec-legal). | ||
| 549 | let (head, body) = harness.get_with_headers( | ||
| 550 | "/release-range-bad/releases/v1/app.bin", | ||
| 551 | &[("Range", "bytes=0-9,20-29")], | ||
| 552 | ); | ||
| 553 | assert!( | ||
| 554 | head.contains("200"), | ||
| 555 | "expected full 200 for multi-range: {}", | ||
| 556 | head | ||
| 557 | ); | ||
| 558 | assert_eq!(body, content); | ||
| 559 | |||
| 560 | // Malformed: same fallback. | ||
| 561 | let (head2, body2) = harness.get_with_headers( | ||
| 562 | "/release-range-bad/releases/v1/app.bin", | ||
| 563 | &[("Range", "not-a-range")], | ||
| 564 | ); | ||
| 565 | assert!( | ||
| 566 | head2.contains("200"), | ||
| 567 | "expected full 200 for malformed range: {}", | ||
| 568 | head2 | ||
| 569 | ); | ||
| 570 | assert_eq!(body2, content); | ||
| 571 | } | ||
| 572 | |||
| 573 | #[test] | ||
| 574 | fn http_release_download_advertises_accept_ranges() { | ||
| 575 | let harness = ServerHarness::new("release-accept-ranges"); | ||
| 576 | harness.push_head(); | ||
| 577 | harness.ssh_exec_with_stdin( | ||
| 578 | "collab-release upload 'release-accept-ranges.git' 'v1' 'a.tar.gz'", | ||
| 579 | b"hello world", | ||
| 580 | ); | ||
| 581 | |||
| 582 | let (head, _) = harness.get_bytes("/release-accept-ranges/releases/v1/a.tar.gz"); | ||
| 583 | assert!(head.contains("200")); | ||
| 584 | assert!( | ||
| 585 | head.to_lowercase().contains("accept-ranges: bytes"), | ||
| 586 | "missing accept-ranges: {}", | ||
| 587 | head | ||
| 588 | ); | ||
| 589 | } | ||
| 590 | |||
| 591 | #[test] | ||
| 592 | fn http_release_conditional_get_returns_304() { | ||
| 593 | let harness = ServerHarness::new("release-etag"); | ||
| 594 | harness.push_head(); | ||
| 595 | harness.ssh_exec_with_stdin( | ||
| 596 | "collab-release upload 'release-etag.git' 'v1' 'a.tar.gz'", | ||
| 597 | b"hello world", | ||
| 598 | ); | ||
| 599 | |||
| 600 | let (head, _) = harness.get_bytes("/release-etag/releases/v1/a.tar.gz"); | ||
| 601 | assert!(head.contains("200")); | ||
| 602 | assert!( | ||
| 603 | head.to_lowercase().contains("etag:"), | ||
| 604 | "missing etag: {}", | ||
| 605 | head | ||
| 606 | ); | ||
| 607 | assert!( | ||
| 608 | head.to_lowercase().contains("last-modified:"), | ||
| 609 | "missing last-modified: {}", | ||
| 610 | head | ||
| 611 | ); | ||
| 612 | assert!( | ||
| 613 | head.to_lowercase().contains("cache-control:"), | ||
| 614 | "missing cache-control: {}", | ||
| 615 | head | ||
| 616 | ); | ||
| 617 | let etag = extract_header(&head, "etag"); | ||
| 618 | |||
| 619 | let (head2, body2) = harness.get_with_headers( | ||
| 620 | "/release-etag/releases/v1/a.tar.gz", | ||
| 621 | &[("If-None-Match", &etag)], | ||
| 622 | ); | ||
| 623 | assert!(head2.contains("304"), "expected 304, got: {}", head2); | ||
| 624 | assert!(body2.is_empty(), "304 must not have a body: {:?}", body2); | ||
| 625 | } | ||
| 626 | |||
| 627 | #[test] | ||
| 628 | fn http_release_if_none_match_weak_comparison_matches() { | ||
| 629 | // RFC 7232 §2.3.2: If-None-Match uses weak comparison, so a client that | ||
| 630 | // stored our strong ETag but replays it with a "W/" prefix (or a client | ||
| 631 | // that legitimately received a weak tag from an intermediary) must still | ||
| 632 | // get a 304, not a spurious full re-download. | ||
| 633 | let harness = ServerHarness::new("release-weak-inm"); | ||
| 634 | harness.push_head(); | ||
| 635 | harness.ssh_exec_with_stdin( | ||
| 636 | "collab-release upload 'release-weak-inm.git' 'v1' 'a.tar.gz'", | ||
| 637 | b"hello world", | ||
| 638 | ); | ||
| 639 | |||
| 640 | let (head, _) = harness.get_bytes("/release-weak-inm/releases/v1/a.tar.gz"); | ||
| 641 | let etag = extract_header(&head, "etag"); | ||
| 642 | let weak_etag = format!("W/{}", etag); | ||
| 643 | |||
| 644 | let (head2, body2) = harness.get_with_headers( | ||
| 645 | "/release-weak-inm/releases/v1/a.tar.gz", | ||
| 646 | &[("If-None-Match", &weak_etag)], | ||
| 647 | ); | ||
| 648 | assert!( | ||
| 649 | head2.contains("304"), | ||
| 650 | "expected 304 for weak-compared matching etag, got: {}", | ||
| 651 | head2 | ||
| 652 | ); | ||
| 653 | assert!(body2.is_empty()); | ||
| 654 | } | ||
| 655 | |||
| 656 | #[test] | ||
| 657 | fn http_release_if_modified_since_round_trip() { | ||
| 658 | let harness = ServerHarness::new("release-ims"); | ||
| 659 | harness.push_head(); | ||
| 660 | harness.ssh_exec_with_stdin( | ||
| 661 | "collab-release upload 'release-ims.git' 'v1' 'a.tar.gz'", | ||
| 662 | b"hello world", | ||
| 663 | ); | ||
| 664 | |||
| 665 | let (head, _) = harness.get_bytes("/release-ims/releases/v1/a.tar.gz"); | ||
| 666 | assert!(head.contains("200")); | ||
| 667 | let last_modified = extract_header(&head, "last-modified"); | ||
| 668 | |||
| 669 | let (head2, body2) = harness.get_with_headers( | ||
| 670 | "/release-ims/releases/v1/a.tar.gz", | ||
| 671 | &[("If-Modified-Since", &last_modified)], | ||
| 672 | ); | ||
| 673 | assert!(head2.contains("304"), "expected 304, got: {}", head2); | ||
| 674 | assert!(body2.is_empty(), "304 must not have a body: {:?}", body2); | ||
| 675 | } | ||
| 676 | |||
| 677 | #[test] | ||
| 678 | fn http_release_range_single_byte_at_start() { | ||
| 679 | let harness = ServerHarness::new("release-range-single"); | ||
| 680 | harness.push_head(); | ||
| 681 | let content: Vec<u8> = (0u16..=255).map(|b| b as u8).collect(); | ||
| 682 | harness.ssh_exec_with_stdin( | ||
| 683 | "collab-release upload 'release-range-single.git' 'v1' 'app.bin'", | ||
| 684 | &content, | ||
| 685 | ); | ||
| 686 | |||
| 687 | let (head, body) = harness.get_with_headers( | ||
| 688 | "/release-range-single/releases/v1/app.bin", | ||
| 689 | &[("Range", "bytes=0-0")], | ||
| 690 | ); | ||
| 691 | assert!(head.contains("206"), "expected 206, got: {}", head); | ||
| 692 | assert!( | ||
| 693 | head.to_lowercase().contains("content-range: bytes 0-0/256"), | ||
| 694 | "missing/wrong content-range: {}", | ||
| 695 | head | ||
| 696 | ); | ||
| 697 | assert_eq!(body, vec![content[0]]); | ||
| 698 | } | ||
| 699 | |||
| 700 | #[test] | ||
| 701 | fn http_release_zero_length_artifact() { | ||
| 702 | let harness = ServerHarness::new("release-zero-len"); | ||
| 703 | harness.push_head(); | ||
| 704 | harness.ssh_exec_with_stdin( | ||
| 705 | "collab-release upload 'release-zero-len.git' 'v1' 'empty.bin'", | ||
| 706 | b"", | ||
| 707 | ); | ||
| 708 | |||
| 709 | // A normal GET on a zero-length file is a 200 with an empty body. | ||
| 710 | let (head, body) = harness.get_bytes("/release-zero-len/releases/v1/empty.bin"); | ||
| 711 | assert!(head.contains("200"), "expected 200, got: {}", head); | ||
| 712 | assert!(body.is_empty()); | ||
| 713 | |||
| 714 | // Any byte range on a zero-length file is unsatisfiable. | ||
| 715 | let (range_head, _) = harness.get_with_headers( | ||
| 716 | "/release-zero-len/releases/v1/empty.bin", | ||
| 717 | &[("Range", "bytes=0-0")], | ||
| 718 | ); | ||
| 719 | assert!( | ||
| 720 | range_head.contains("416"), | ||
| 721 | "expected 416, got: {}", | ||
| 722 | range_head | ||
| 723 | ); | ||
| 724 | assert!( | ||
| 725 | range_head | ||
| 726 | .to_lowercase() | ||
| 727 | .contains("content-range: bytes */0"), | ||
| 728 | "missing/wrong content-range: {}", | ||
| 729 | range_head | ||
| 730 | ); | ||
| 731 | } | ||
| 732 | |||
| 733 | /// Reproduces the review finding directly: a client fetches the first half | ||
| 734 | /// of an artifact, the artifact is `--force`-replaced with different bytes | ||
| 735 | /// of the same length, and the client resumes presenting the validator it | ||
| 736 | /// still holds via `If-Range`. The server must detect the mismatch and | ||
| 737 | /// serve the full, current artifact rather than splicing old and new bytes | ||
| 738 | /// into a single response. | ||
| 739 | #[test] | ||
| 740 | fn http_release_if_range_etag_falls_back_to_full_after_force_replace() { | ||
| 741 | let harness = ServerHarness::new("release-if-range-stale"); | ||
| 742 | harness.push_head(); | ||
| 743 | let original: Vec<u8> = (0u8..20).collect(); | ||
| 744 | harness.ssh_exec_with_stdin( | ||
| 745 | "collab-release upload 'release-if-range-stale.git' 'v1' 'app.bin'", | ||
| 746 | &original, | ||
| 747 | ); | ||
| 748 | |||
| 749 | let (head1, body1) = harness.get_with_headers( | ||
| 750 | "/release-if-range-stale/releases/v1/app.bin", | ||
| 751 | &[("Range", "bytes=0-9")], | ||
| 752 | ); | ||
| 753 | assert!(head1.contains("206"), "expected 206, got: {}", head1); | ||
| 754 | assert_eq!(body1, original[0..10]); | ||
| 755 | let stale_etag = extract_header(&head1, "etag"); | ||
| 756 | |||
| 757 | let replaced: Vec<u8> = (100u8..120).collect(); | ||
| 758 | let force = harness.ssh_exec_with_stdin( | ||
| 759 | "collab-release upload 'release-if-range-stale.git' 'v1' 'app.bin' --force", | ||
| 760 | &replaced, | ||
| 761 | ); | ||
| 762 | assert!(force.status.success(), "force upload failed: {:?}", force); | ||
| 763 | |||
| 764 | let (head2, body2) = harness.get_with_headers( | ||
| 765 | "/release-if-range-stale/releases/v1/app.bin", | ||
| 766 | &[("Range", "bytes=10-19"), ("If-Range", &stale_etag)], | ||
| 767 | ); | ||
| 768 | assert_eq!( | ||
| 769 | status_line(&head2), | ||
| 770 | "HTTP/1.1 200 OK", | ||
| 771 | "expected a full 200 when If-Range is stale (never splice), got: {}", | ||
| 772 | head2 | ||
| 773 | ); | ||
| 774 | assert_eq!( | ||
| 775 | body2, replaced, | ||
| 776 | "must serve the full current artifact, not a splice of old+new bytes" | ||
| 777 | ); | ||
| 778 | |||
| 779 | // Sanity: the "assembled from a stale resume" bytes really would not | ||
| 780 | // have matched anything real, proving this isn't a vacuous check. | ||
| 781 | let mut spliced = original[0..10].to_vec(); | ||
| 782 | spliced.extend_from_slice(&replaced[10..20]); | ||
| 783 | assert_ne!(body2, spliced); | ||
| 784 | } | ||
| 785 | |||
| 786 | #[test] | ||
| 787 | fn http_release_if_range_etag_matching_honors_range() { | ||
| 788 | let harness = ServerHarness::new("release-if-range-match"); | ||
| 789 | harness.push_head(); | ||
| 790 | let content: Vec<u8> = (0u8..20).collect(); | ||
| 791 | harness.ssh_exec_with_stdin( | ||
| 792 | "collab-release upload 'release-if-range-match.git' 'v1' 'app.bin'", | ||
| 793 | &content, | ||
| 794 | ); | ||
| 795 | |||
| 796 | let (head1, _) = harness.get_bytes("/release-if-range-match/releases/v1/app.bin"); | ||
| 797 | let etag = extract_header(&head1, "etag"); | ||
| 798 | |||
| 799 | let (head2, body2) = harness.get_with_headers( | ||
| 800 | "/release-if-range-match/releases/v1/app.bin", | ||
| 801 | &[("Range", "bytes=10-19"), ("If-Range", &etag)], | ||
| 802 | ); | ||
| 803 | assert!( | ||
| 804 | head2.contains("206"), | ||
| 805 | "expected 206 when If-Range matches, got: {}", | ||
| 806 | head2 | ||
| 807 | ); | ||
| 808 | assert_eq!(body2, content[10..20]); | ||
| 809 | } | ||
| 810 | |||
| 811 | #[test] | ||
| 812 | fn http_release_if_range_date_form_match_honors_range() { | ||
| 813 | let harness = ServerHarness::new("release-if-range-date-match"); | ||
| 814 | harness.push_head(); | ||
| 815 | let content: Vec<u8> = (0u8..20).collect(); | ||
| 816 | harness.ssh_exec_with_stdin( | ||
| 817 | "collab-release upload 'release-if-range-date-match.git' 'v1' 'app.bin'", | ||
| 818 | &content, | ||
| 819 | ); | ||
| 820 | |||
| 821 | let (head1, _) = harness.get_bytes("/release-if-range-date-match/releases/v1/app.bin"); | ||
| 822 | let last_modified = extract_header(&head1, "last-modified"); | ||
| 823 | |||
| 824 | let (head2, body2) = harness.get_with_headers( | ||
| 825 | "/release-if-range-date-match/releases/v1/app.bin", | ||
| 826 | &[("Range", "bytes=10-19"), ("If-Range", &last_modified)], | ||
| 827 | ); | ||
| 828 | assert!( | ||
| 829 | head2.contains("206"), | ||
| 830 | "expected 206 when If-Range date matches, got: {}", | ||
| 831 | head2 | ||
| 832 | ); | ||
| 833 | assert_eq!(body2, content[10..20]); | ||
| 834 | } | ||
| 835 | |||
| 836 | #[test] | ||
| 837 | fn http_release_if_range_date_form_mismatch_falls_back_to_full() { | ||
| 838 | let harness = ServerHarness::new("release-if-range-date-bad"); | ||
| 839 | harness.push_head(); | ||
| 840 | let content: Vec<u8> = (0u8..20).collect(); | ||
| 841 | harness.ssh_exec_with_stdin( | ||
| 842 | "collab-release upload 'release-if-range-date-bad.git' 'v1' 'app.bin'", | ||
| 843 | &content, | ||
| 844 | ); | ||
| 845 | |||
| 846 | let (head, body) = harness.get_with_headers( | ||
| 847 | "/release-if-range-date-bad/releases/v1/app.bin", | ||
| 848 | &[ | ||
| 849 | ("Range", "bytes=10-19"), | ||
| 850 | ("If-Range", "Sun, 06 Nov 1994 08:49:37 GMT"), | ||
| 851 | ], | ||
| 852 | ); | ||
| 853 | assert_eq!( | ||
| 854 | status_line(&head), | ||
| 855 | "HTTP/1.1 200 OK", | ||
| 856 | "expected full 200 for a non-matching If-Range date, got: {}", | ||
| 857 | head | ||
| 858 | ); | ||
| 859 | assert_eq!(body, content); | ||
| 860 | } | ||
| 861 | |||
| 862 | /// Byte-identical 404s for policy-denied repos is a deliberate security | ||
| 863 | /// property (private repos must not be distinguishable from missing ones). | ||
| 864 | /// This pins that the property survives regardless of which conditional or | ||
| 865 | /// range headers a probing client sends — currently guarded only by the | ||
| 866 | /// early-return ordering in `release_download`, which a refactor could move. | ||
| 867 | /// | ||
| 868 | /// The comparison baseline is a repo that was never created at all (`None` | ||
| 869 | /// out of `crate::repos::resolve`), not another response from the denied | ||
| 870 | /// repo itself — the property under test is "denied is indistinguishable | ||
| 871 | /// from absent," and only an independently-absent repo proves that. | ||
| 872 | /// | ||
| 873 | /// The conditional-header variants are chosen to include the two that can | ||
| 874 | /// actually produce a *different* status code (304) if the policy gate ever | ||
| 875 | /// moved below the conditional-GET check: `If-None-Match: *` unconditionally | ||
| 876 | /// matches any existing resource, and a future `If-Modified-Since` is always | ||
| 877 | /// satisfied. `"whatever"`/a fixed past date can never yield anything but | ||
| 878 | /// a plain miss, so on their own they can't detect that particular | ||
| 879 | /// regression — both kinds are included so this test still means something | ||
| 880 | /// if either check is later changed. | ||
| 881 | #[test] | ||
| 882 | fn http_release_download_404_is_identical_regardless_of_conditional_headers() { | ||
| 883 | let harness = ServerHarness::new("release-404-headers"); | ||
| 884 | harness.push_head(); | ||
| 885 | harness.ssh_exec_with_stdin( | ||
| 886 | "collab-release upload 'release-404-headers.git' 'v1' 'a.tar.gz'", | ||
| 887 | b"secret", | ||
| 888 | ); | ||
| 889 | harness.write_repo_server_policy( | ||
| 890 | "visibility = \"private\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n", | ||
| 891 | ); | ||
| 892 | |||
| 893 | let denied_path = "/release-404-headers/releases/v1/a.tar.gz"; | ||
| 894 | // This repo was never created; resolve() returns None for it regardless | ||
| 895 | // of any conditional header, which is exactly the ground truth "absent" | ||
| 896 | // response the denied repo must stay indistinguishable from. | ||
| 897 | let nonexistent_path = "/release-404-headers-does-not-exist/releases/v1/a.tar.gz"; | ||
| 898 | |||
| 899 | let (absent_head, absent_body) = harness.get_bytes(nonexistent_path); | ||
| 900 | assert_eq!(status_line(&absent_head), "HTTP/1.1 404 Not Found"); | ||
| 901 | |||
| 902 | let variants: &[&[(&str, &str)]] = &[ | ||
| 903 | &[("Range", "bytes=0-9")], | ||
| 904 | &[("If-None-Match", "\"whatever\"")], | ||
| 905 | &[("If-None-Match", "*")], | ||
| 906 | &[("If-Modified-Since", "Sun, 06 Nov 1994 08:49:37 GMT")], | ||
| 907 | // A real Monday: chrono's `%a` validates the weekday name, so a | ||
| 908 | // wrong day would silently fail to parse and make this variant | ||
| 909 | // vacuous (indistinguishable from the past-date case above). | ||
| 910 | &[("If-Modified-Since", "Mon, 01 Jan 2035 00:00:00 GMT")], | ||
| 911 | &[("If-Range", "\"whatever\"")], | ||
| 912 | ]; | ||
| 913 | for headers in variants { | ||
| 914 | let (head, body) = harness.get_with_headers(denied_path, headers); | ||
| 915 | assert_eq!( | ||
| 916 | status_line(&head), | ||
| 917 | "HTTP/1.1 404 Not Found", | ||
| 918 | "denied repo status differs from absent for {:?}: {}", | ||
| 919 | headers, | ||
| 920 | head | ||
| 921 | ); | ||
| 922 | assert_eq!( | ||
| 923 | strip_date_header(&head), | ||
| 924 | strip_date_header(&absent_head), | ||
| 925 | "denied-repo response headers differ from a nonexistent repo's for {:?}", | ||
| 926 | headers | ||
| 927 | ); | ||
| 928 | assert_eq!( | ||
| 929 | body, absent_body, | ||
| 930 | "denied-repo body differs from a nonexistent repo's for {:?}", | ||
| 931 | headers | ||
| 932 | ); | ||
| 933 | } | ||
| 934 | } | ||
| 935 | |||
| 936 | #[test] | ||
| 937 | fn http_release_download_sets_nosniff() { | ||
| 938 | let harness = ServerHarness::new("release-nosniff"); | ||
| 939 | harness.push_head(); | ||
| 940 | harness.ssh_exec_with_stdin( | ||
| 941 | "collab-release upload 'release-nosniff.git' 'v1' 'a.tar.gz'", | ||
| 942 | b"hello", | ||
| 943 | ); | ||
| 944 | |||
| 945 | let (head, _) = harness.get_bytes("/release-nosniff/releases/v1/a.tar.gz"); | ||
| 946 | assert!( | ||
| 947 | head.to_lowercase() | ||
| 948 | .contains("x-content-type-options: nosniff"), | ||
| 949 | "missing nosniff header: {}", | ||
| 950 | head | ||
| 951 | ); | ||
| 952 | } | ||
| 953 | |||
| 954 | #[test] | ||
| 955 | fn http_releases_page_hides_download_links_when_downloads_disabled() { | ||
| 956 | let harness = ServerHarness::new("release-deadlink"); | ||
| 957 | harness.push_head(); | ||
| 958 | harness.ssh_exec_with_stdin( | ||
| 959 | "collab-release upload 'release-deadlink.git' 'v1' 'a.tar.gz'", | ||
| 960 | b"content", | ||
| 961 | ); | ||
| 962 | harness.write_repo_server_policy( | ||
| 963 | "visibility = \"public\"\n[ui]\nanonymous = true\n[http]\nanonymous_clone = false\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n", | ||
| 964 | ); | ||
| 965 | |||
| 966 | let page = harness.get_ok("/release-deadlink/releases"); | ||
| 967 | assert!(page.body.contains("v1")); | ||
| 968 | assert!(page.body.contains("a.tar.gz")); | ||
| 969 | assert!( | ||
| 970 | !page | ||
| 971 | .body | ||
| 972 | .contains("href=\"/release-deadlink/releases/v1/a.tar.gz\""), | ||
| 973 | "download link must not be rendered when downloads are disabled:\n{}", | ||
| 974 | page.body | ||
| 975 | ); | ||
| 976 | let lower = page.body.to_lowercase(); | ||
| 977 | assert!( | ||
| 978 | lower.contains("not") && lower.contains("available"), | ||
| 979 | "expected a note that downloads are unavailable:\n{}", | ||
| 980 | page.body | ||
| 981 | ); | ||
| 982 | } | ||