mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 05:40:31 +00:00
* 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)
293 lines
8.1 KiB
Rust
293 lines
8.1 KiB
Rust
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
use crate::activity_log::{ActionType, ActivityEntry};
|
|
use crate::audit::AuditEntry;
|
|
use crate::registry::proxy_fetch;
|
|
use crate::AppState;
|
|
use axum::{
|
|
body::Bytes,
|
|
extract::{Path, State},
|
|
http::{header, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
routing::{get, put},
|
|
Router,
|
|
};
|
|
use std::sync::Arc;
|
|
|
|
pub fn routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/maven2/{*path}", get(download))
|
|
.route("/maven2/{*path}", put(upload))
|
|
}
|
|
|
|
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
|
let key = format!("maven/{}", path);
|
|
|
|
let artifact_name = path
|
|
.split('/')
|
|
.rev()
|
|
.take(3)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
|
|
if let Ok(data) = state.storage.get(&key).await {
|
|
state.metrics.record_download("maven");
|
|
state.metrics.record_cache_hit();
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::CacheHit,
|
|
artifact_name,
|
|
"maven",
|
|
"CACHE",
|
|
));
|
|
state
|
|
.audit
|
|
.log(AuditEntry::new("cache_hit", "api", "", "maven", ""));
|
|
return with_content_type(&path, data).into_response();
|
|
}
|
|
|
|
for proxy in &state.config.maven.proxies {
|
|
let url = format!("{}/{}", proxy.url().trim_end_matches('/'), path);
|
|
|
|
match proxy_fetch(
|
|
&state.http_client,
|
|
&url,
|
|
state.config.maven.proxy_timeout,
|
|
proxy.auth(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(data) => {
|
|
state.metrics.record_download("maven");
|
|
state.metrics.record_cache_miss();
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::ProxyFetch,
|
|
artifact_name,
|
|
"maven",
|
|
"PROXY",
|
|
));
|
|
state
|
|
.audit
|
|
.log(AuditEntry::new("proxy_fetch", "api", "", "maven", ""));
|
|
|
|
let storage = state.storage.clone();
|
|
let key_clone = key.clone();
|
|
let data_clone = data.clone();
|
|
tokio::spawn(async move {
|
|
let _ = storage.put(&key_clone, &data_clone).await;
|
|
});
|
|
|
|
state.repo_index.invalidate("maven");
|
|
|
|
return with_content_type(&path, data.into()).into_response();
|
|
}
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
|
|
StatusCode::NOT_FOUND.into_response()
|
|
}
|
|
|
|
async fn upload(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(path): Path<String>,
|
|
body: Bytes,
|
|
) -> StatusCode {
|
|
let key = format!("maven/{}", path);
|
|
|
|
let artifact_name = path
|
|
.split('/')
|
|
.rev()
|
|
.take(3)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
|
|
match state.storage.put(&key, &body).await {
|
|
Ok(()) => {
|
|
state.metrics.record_upload("maven");
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::Push,
|
|
artifact_name,
|
|
"maven",
|
|
"LOCAL",
|
|
));
|
|
state
|
|
.audit
|
|
.log(AuditEntry::new("push", "api", "", "maven", ""));
|
|
state.repo_index.invalidate("maven");
|
|
StatusCode::CREATED
|
|
}
|
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
}
|
|
}
|
|
|
|
fn with_content_type(
|
|
path: &str,
|
|
data: Bytes,
|
|
) -> (StatusCode, [(header::HeaderName, &'static str); 1], Bytes) {
|
|
let content_type = if path.ends_with(".pom") {
|
|
"application/xml"
|
|
} else if path.ends_with(".jar") {
|
|
"application/java-archive"
|
|
} else if path.ends_with(".xml") {
|
|
"application/xml"
|
|
} else if path.ends_with(".sha1") || path.ends_with(".md5") {
|
|
"text/plain"
|
|
} else {
|
|
"application/octet-stream"
|
|
};
|
|
|
|
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_content_type_pom() {
|
|
let (status, headers, _) =
|
|
with_content_type("com/example/1.0/example-1.0.pom", Bytes::from("data"));
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(headers[0].1, "application/xml");
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_type_jar() {
|
|
let (_, headers, _) =
|
|
with_content_type("com/example/1.0/example-1.0.jar", Bytes::from("data"));
|
|
assert_eq!(headers[0].1, "application/java-archive");
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_type_xml() {
|
|
let (_, headers, _) =
|
|
with_content_type("com/example/maven-metadata.xml", Bytes::from("data"));
|
|
assert_eq!(headers[0].1, "application/xml");
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_type_sha1() {
|
|
let (_, headers, _) =
|
|
with_content_type("com/example/1.0/example-1.0.jar.sha1", Bytes::from("data"));
|
|
assert_eq!(headers[0].1, "text/plain");
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_type_md5() {
|
|
let (_, headers, _) =
|
|
with_content_type("com/example/1.0/example-1.0.jar.md5", Bytes::from("data"));
|
|
assert_eq!(headers[0].1, "text/plain");
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_type_unknown() {
|
|
let (_, headers, _) = with_content_type("some/random/file.bin", Bytes::from("data"));
|
|
assert_eq!(headers[0].1, "application/octet-stream");
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_type_preserves_body() {
|
|
let body = Bytes::from("test-jar-content");
|
|
let (_, _, data) = with_content_type("test.jar", body.clone());
|
|
assert_eq!(data, body);
|
|
}
|
|
}
|
|
|
|
#[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::{header, Method, StatusCode};
|
|
|
|
#[tokio::test]
|
|
async fn test_maven_put_get_roundtrip() {
|
|
let ctx = create_test_context();
|
|
let jar_data = b"fake-jar-content";
|
|
|
|
let put = send(
|
|
&ctx.app,
|
|
Method::PUT,
|
|
"/maven2/com/example/mylib/1.0/mylib-1.0.jar",
|
|
Body::from(&jar_data[..]),
|
|
)
|
|
.await;
|
|
assert_eq!(put.status(), StatusCode::CREATED);
|
|
|
|
let get = send(
|
|
&ctx.app,
|
|
Method::GET,
|
|
"/maven2/com/example/mylib/1.0/mylib-1.0.jar",
|
|
"",
|
|
)
|
|
.await;
|
|
assert_eq!(get.status(), StatusCode::OK);
|
|
let body = body_bytes(get).await;
|
|
assert_eq!(&body[..], jar_data);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_maven_not_found_no_proxy() {
|
|
let ctx = create_test_context();
|
|
let resp = send(
|
|
&ctx.app,
|
|
Method::GET,
|
|
"/maven2/missing/artifact/1.0/artifact-1.0.jar",
|
|
"",
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_maven_content_type_pom() {
|
|
let ctx = create_test_context();
|
|
send(
|
|
&ctx.app,
|
|
Method::PUT,
|
|
"/maven2/com/ex/1.0/ex-1.0.pom",
|
|
Body::from("<project/>"),
|
|
)
|
|
.await;
|
|
|
|
let get = send(&ctx.app, Method::GET, "/maven2/com/ex/1.0/ex-1.0.pom", "").await;
|
|
assert_eq!(get.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
get.headers().get(header::CONTENT_TYPE).unwrap(),
|
|
"application/xml"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_maven_content_type_jar() {
|
|
let ctx = create_test_context();
|
|
send(
|
|
&ctx.app,
|
|
Method::PUT,
|
|
"/maven2/org/test/app/2.0/app-2.0.jar",
|
|
Body::from("jar-data"),
|
|
)
|
|
.await;
|
|
|
|
let get = send(
|
|
&ctx.app,
|
|
Method::GET,
|
|
"/maven2/org/test/app/2.0/app-2.0.jar",
|
|
"",
|
|
)
|
|
.await;
|
|
assert_eq!(get.status(), StatusCode::OK);
|
|
assert_eq!(
|
|
get.headers().get(header::CONTENT_TYPE).unwrap(),
|
|
"application/java-archive"
|
|
);
|
|
}
|
|
}
|