quality: MSRV, tarpaulin config, proptest for parsers (#84)

* fix: proxy dedup, multi-registry GC, TOCTOU and credential hygiene

- Deduplicate proxy_fetch/proxy_fetch_text into generic proxy_fetch_core
  with response extractor closure (removes ~50 lines of copy-paste)
- GC now scans all registry prefixes, not just docker/
- Add tracing::warn to fire-and-forget cache writes in docker proxy
- Mark S3 credentials as skip_serializing to prevent accidental leaks
- Remove TOCTOU race in LocalStorage get/delete (redundant exists check)

* chore: clean up root directory structure

- Move Dockerfile.astra and Dockerfile.redos to deploy/ (niche builds
  should not clutter the project root)
- Harden .gitignore to exclude session files, working notes, and
  internal review scripts

* refactor(metrics): replace 13 atomic fields with CounterMap

Per-registry download/upload counters were 13 individual AtomicU64
fields, each duplicated across new(), with_persistence(), save(),
record_download(), record_upload(), and get_registry_* (6 touch points
per counter). Adding a new registry required changes in 6+ places.

Now uses CounterMap (HashMap<String, AtomicU64>) for per-registry
counters. Adding a new registry = one entry in REGISTRIES const.
Added Go registry to REGISTRIES, gaining go metrics for free.

* quality: add MSRV, tarpaulin config, proptest for parsers

- Set rust-version = 1.75 in workspace Cargo.toml (MSRV policy)
- Add tarpaulin.toml: llvm engine, fail-under=25, json+html output
- Add coverage/ to .gitignore
- Update CI to use tarpaulin.toml instead of inline flags
- Add proptest dev-dependency and property tests:
  - validation.rs: 16 tests (never-panics + invariants for all 4 validators)
  - pypi.rs: 5 tests (extract_filename never-panics + format assertions)

* test: add unit tests for 14 modules, coverage 21% → 30%

Add 149 new tests across auth, backup, gc, metrics, mirror parsers,
docker (manifest detection, session cleanup, metadata serde),
docker_auth (token cache), maven, npm, pypi (normalize, rewrite, extract),
raw (content-type guessing), request_id, and s3 (URI encoding).

Update tarpaulin.toml: raise fail-under to 30, exclude UI/main from
coverage reporting as they require integration tests.

* bench: add criterion benchmarks for validation and manifest parsing

Add parsing benchmark suite with 14 benchmarks covering:
- Storage key, Docker name, digest, and reference validation
- Docker manifest media type detection (v2, OCI index, minimal, invalid)

Run with: cargo bench --package nora-registry --bench parsing

* test: add 48 integration tests via tower oneshot

Add integration tests for all HTTP handlers:
- health (3), raw (7), cargo (4), maven (4), request_id (2)
- pypi (5), npm (5), docker (12), auth (6)

Create test_helpers.rs with TestContext pattern.
Add tower and http-body-util dev-dependencies.
Update tarpaulin fail-under 30 to 40.

Coverage: 29.5% to 43.3% (2089/4825 lines)

* fix: clean clippy warnings in tests, fix flaky audit test

Add #[allow(clippy::unwrap_used)] to 18 test modules.
Fix 3 additional clippy lints: writeln_empty_string, needless_update,
unnecessary_get_then_check.
Fix flaky audit test: replace single sleep(50ms) with retry loop (max 1s).
Prefix unused token variable with underscore.

cargo clippy --all-targets = 0 warnings (was 245 errors)
This commit is contained in:
2026-04-05 10:01:50 +03:00
committed by GitHub
parent 35a9e34a3e
commit ac3a8a7c43
37 changed files with 3452 additions and 130 deletions

View File

@@ -432,6 +432,7 @@ fn with_content_type(
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
@@ -555,4 +556,229 @@ mod tests {
assert!(!is_valid_attachment_name(""));
assert!(!is_valid_attachment_name("foo\0bar.tgz"));
}
#[test]
fn test_with_content_type_tarball() {
let data = Bytes::from("tarball-data");
let (status, headers, body) = with_content_type(true, data.clone());
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[0].1, "application/octet-stream");
assert_eq!(body, data);
}
#[test]
fn test_with_content_type_json() {
let data = Bytes::from("json-data");
let (status, headers, body) = with_content_type(false, data.clone());
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[0].1, "application/json");
assert_eq!(body, data);
}
#[test]
fn test_rewrite_tarball_urls_trailing_slash() {
let metadata = serde_json::json!({
"name": "test",
"versions": {
"1.0.0": {
"dist": {
"tarball": "https://registry.npmjs.org/test/-/test-1.0.0.tgz"
}
}
}
});
let data = serde_json::to_vec(&metadata).unwrap();
let result =
rewrite_tarball_urls(&data, "http://nora:5000/", "https://registry.npmjs.org/")
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
let tarball = json["versions"]["1.0.0"]["dist"]["tarball"]
.as_str()
.unwrap();
assert!(tarball.starts_with("http://nora:5000/npm/"));
}
#[test]
fn test_rewrite_tarball_urls_preserves_other_fields() {
let metadata = serde_json::json!({
"name": "test",
"description": "A test package",
"versions": {
"1.0.0": {
"dist": {
"tarball": "https://registry.npmjs.org/test/-/test-1.0.0.tgz",
"shasum": "abc123"
},
"dependencies": {"lodash": "^4.0.0"}
}
}
});
let data = serde_json::to_vec(&metadata).unwrap();
let result =
rewrite_tarball_urls(&data, "http://nora:5000", "https://registry.npmjs.org").unwrap();
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
assert_eq!(json["description"], "A test package");
assert_eq!(json["versions"]["1.0.0"]["dist"]["shasum"], "abc123");
}
#[test]
fn test_is_valid_attachment_name_valid() {
assert!(is_valid_attachment_name("package-1.0.0.tgz"));
assert!(is_valid_attachment_name("@scope-pkg-2.0.tgz"));
assert!(is_valid_attachment_name("my_pkg.tgz"));
}
#[test]
fn test_is_valid_attachment_name_traversal() {
assert!(!is_valid_attachment_name("../etc/passwd"));
assert!(!is_valid_attachment_name("foo/../bar"));
}
#[test]
fn test_is_valid_attachment_name_slash() {
assert!(!is_valid_attachment_name("path/file.tgz"));
assert!(!is_valid_attachment_name("path\\file.tgz"));
}
#[test]
fn test_is_valid_attachment_name_null_byte() {
assert!(!is_valid_attachment_name("file\0.tgz"));
}
#[test]
fn test_is_valid_attachment_name_empty() {
assert!(!is_valid_attachment_name(""));
}
#[test]
fn test_is_valid_attachment_name_special_chars() {
assert!(!is_valid_attachment_name("file name.tgz")); // space
assert!(!is_valid_attachment_name("file;cmd.tgz")); // semicolon
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod integration_tests {
use crate::test_helpers::{body_bytes, create_test_context, send};
use axum::body::Body;
use axum::http::{Method, StatusCode};
use base64::Engine;
#[tokio::test]
async fn test_npm_metadata_from_cache() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "lodash",
"versions": {
"4.17.21": { "dist": { "tarball": "http://example.com/lodash.tgz" } }
}
});
let metadata_bytes = serde_json::to_vec(&metadata).unwrap();
ctx.state
.storage
.put("npm/lodash/metadata.json", &metadata_bytes)
.await
.unwrap();
let response = send(&ctx.app, Method::GET, "/npm/lodash", "").await;
assert_eq!(response.status(), StatusCode::OK);
let body = body_bytes(response).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "lodash");
}
#[tokio::test]
async fn test_npm_tarball_from_cache() {
let ctx = create_test_context();
let tarball_data = b"fake-tarball-bytes";
ctx.state
.storage
.put("npm/lodash/tarballs/lodash-4.17.21.tgz", tarball_data)
.await
.unwrap();
let response = send(
&ctx.app,
Method::GET,
"/npm/lodash/-/lodash-4.17.21.tgz",
"",
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = body_bytes(response).await;
assert_eq!(&body[..], tarball_data);
}
#[tokio::test]
async fn test_npm_not_found_no_proxy() {
let ctx = create_test_context();
// No proxy configured, no local data
let response = send(&ctx.app, Method::GET, "/npm/nonexistent", "").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_npm_publish_basic() {
let ctx = create_test_context();
let tarball_data = b"fake-tarball";
let base64_data = base64::engine::general_purpose::STANDARD.encode(tarball_data);
let payload = serde_json::json!({
"name": "mypkg",
"versions": {
"1.0.0": { "dist": {} }
},
"_attachments": {
"mypkg-1.0.0.tgz": { "data": base64_data }
},
"dist-tags": { "latest": "1.0.0" }
});
let body_bytes = serde_json::to_vec(&payload).unwrap();
let response = send(&ctx.app, Method::PUT, "/npm/mypkg", Body::from(body_bytes)).await;
assert_eq!(response.status(), StatusCode::CREATED);
// Verify tarball was stored
let stored_tarball = ctx
.state
.storage
.get("npm/mypkg/tarballs/mypkg-1.0.0.tgz")
.await
.unwrap();
assert_eq!(&stored_tarball[..], tarball_data);
}
#[tokio::test]
async fn test_npm_publish_name_mismatch() {
let ctx = create_test_context();
let tarball_data = b"fake-tarball";
let base64_data = base64::engine::general_purpose::STANDARD.encode(tarball_data);
let payload = serde_json::json!({
"name": "other",
"versions": {
"1.0.0": { "dist": {} }
},
"_attachments": {
"other-1.0.0.tgz": { "data": base64_data }
},
"dist-tags": { "latest": "1.0.0" }
});
let body_bytes = serde_json::to_vec(&payload).unwrap();
let response = send(&ctx.app, Method::PUT, "/npm/mypkg", Body::from(body_bytes)).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
}