a73x

0dcd14f5

Support range requests and cache validators on release downloads

a73x   2026-08-08 18:18

Commit message
Support range requests and cache validators on release downloads

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, &current_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 }