mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 20:50: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)
785 lines
26 KiB
Rust
785 lines
26 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 base64::Engine;
|
|
use sha2::Digest;
|
|
use std::sync::Arc;
|
|
|
|
pub fn routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/npm/{*path}", get(handle_request))
|
|
.route("/npm/{*path}", put(handle_publish))
|
|
}
|
|
|
|
/// Build NORA base URL from config (for URL rewriting)
|
|
fn nora_base_url(state: &AppState) -> String {
|
|
state.config.server.public_url.clone().unwrap_or_else(|| {
|
|
format!(
|
|
"http://{}:{}",
|
|
state.config.server.host, state.config.server.port
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Rewrite tarball URLs in npm metadata to point to NORA.
|
|
///
|
|
/// Replaces upstream registry URLs (e.g. `https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz`)
|
|
/// with NORA URLs (e.g. `http://nora:5000/npm/lodash/-/lodash-4.17.21.tgz`).
|
|
fn rewrite_tarball_urls(data: &[u8], nora_base: &str, upstream_url: &str) -> Result<Vec<u8>, ()> {
|
|
let mut json: serde_json::Value = serde_json::from_slice(data).map_err(|_| ())?;
|
|
|
|
let upstream_trimmed = upstream_url.trim_end_matches('/');
|
|
let nora_npm_base = format!("{}/npm", nora_base.trim_end_matches('/'));
|
|
|
|
if let Some(versions) = json.get_mut("versions").and_then(|v| v.as_object_mut()) {
|
|
for (_ver, version_data) in versions.iter_mut() {
|
|
if let Some(tarball_url) = version_data
|
|
.get("dist")
|
|
.and_then(|d| d.get("tarball"))
|
|
.and_then(|t| t.as_str())
|
|
.map(|s| s.to_string())
|
|
{
|
|
let rewritten = tarball_url.replace(upstream_trimmed, &nora_npm_base);
|
|
if let Some(dist) = version_data.get_mut("dist") {
|
|
dist["tarball"] = serde_json::Value::String(rewritten);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
serde_json::to_vec(&json).map_err(|_| ())
|
|
}
|
|
|
|
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
|
let is_tarball = path.contains("/-/");
|
|
|
|
let key = if is_tarball {
|
|
let parts: Vec<&str> = path.splitn(2, "/-/").collect();
|
|
if parts.len() == 2 {
|
|
format!("npm/{}/tarballs/{}", parts[0], parts[1])
|
|
} else {
|
|
format!("npm/{}", path)
|
|
}
|
|
} else {
|
|
format!("npm/{}/metadata.json", path)
|
|
};
|
|
|
|
let package_name = if is_tarball {
|
|
path.split("/-/").next().unwrap_or(&path).to_string()
|
|
} else {
|
|
path.clone()
|
|
};
|
|
|
|
// --- Cache hit path ---
|
|
if let Ok(data) = state.storage.get(&key).await {
|
|
// Metadata TTL: if stale, try to refetch from upstream
|
|
if !is_tarball {
|
|
let ttl = state.config.npm.metadata_ttl;
|
|
if ttl > 0 {
|
|
if let Some(meta) = state.storage.stat(&key).await {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
if now.saturating_sub(meta.modified) > ttl {
|
|
if let Some(fresh) = refetch_metadata(&state, &path, &key).await {
|
|
return with_content_type(false, fresh.into()).into_response();
|
|
}
|
|
// Upstream failed — serve stale cache
|
|
}
|
|
}
|
|
}
|
|
return with_content_type(false, data).into_response();
|
|
}
|
|
|
|
// Tarball: integrity check if hash exists
|
|
let hash_key = format!("{}.sha256", key);
|
|
if let Ok(stored_hash) = state.storage.get(&hash_key).await {
|
|
let computed = hex::encode(sha2::Sha256::digest(&data));
|
|
let expected = String::from_utf8_lossy(&stored_hash);
|
|
if computed != expected.as_ref() {
|
|
tracing::error!(
|
|
key = %key,
|
|
expected = %expected,
|
|
computed = %computed,
|
|
"SECURITY: npm tarball integrity check FAILED — possible tampering"
|
|
);
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Integrity check failed")
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
state.metrics.record_download("npm");
|
|
state.metrics.record_cache_hit();
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::CacheHit,
|
|
package_name,
|
|
"npm",
|
|
"CACHE",
|
|
));
|
|
state
|
|
.audit
|
|
.log(AuditEntry::new("cache_hit", "api", "", "npm", ""));
|
|
return with_content_type(true, data).into_response();
|
|
}
|
|
|
|
// --- Proxy fetch path ---
|
|
if let Some(proxy_url) = &state.config.npm.proxy {
|
|
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
|
|
|
if let Ok(data) = proxy_fetch(
|
|
&state.http_client,
|
|
&url,
|
|
state.config.npm.proxy_timeout,
|
|
state.config.npm.proxy_auth.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
let data_to_cache;
|
|
let data_to_serve;
|
|
|
|
if is_tarball {
|
|
// Compute and store sha256
|
|
let hash = hex::encode(sha2::Sha256::digest(&data));
|
|
let hash_key = format!("{}.sha256", key);
|
|
let storage = state.storage.clone();
|
|
tokio::spawn(async move {
|
|
let _ = storage.put(&hash_key, hash.as_bytes()).await;
|
|
});
|
|
|
|
state.metrics.record_download("npm");
|
|
state.metrics.record_cache_miss();
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::ProxyFetch,
|
|
package_name,
|
|
"npm",
|
|
"PROXY",
|
|
));
|
|
state
|
|
.audit
|
|
.log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
|
|
|
|
data_to_cache = data.clone();
|
|
data_to_serve = data;
|
|
} else {
|
|
// Metadata: rewrite tarball URLs to point to NORA
|
|
let nora_base = nora_base_url(&state);
|
|
let rewritten = rewrite_tarball_urls(&data, &nora_base, proxy_url).unwrap_or(data);
|
|
|
|
data_to_cache = rewritten.clone();
|
|
data_to_serve = rewritten;
|
|
}
|
|
|
|
// Cache in background
|
|
let storage = state.storage.clone();
|
|
let key_clone = key.clone();
|
|
tokio::spawn(async move {
|
|
let _ = storage.put(&key_clone, &data_to_cache).await;
|
|
});
|
|
|
|
if is_tarball {
|
|
state.repo_index.invalidate("npm");
|
|
}
|
|
|
|
return with_content_type(is_tarball, data_to_serve.into()).into_response();
|
|
}
|
|
}
|
|
|
|
StatusCode::NOT_FOUND.into_response()
|
|
}
|
|
|
|
/// Refetch metadata from upstream, rewrite URLs, update cache.
|
|
/// Returns None if upstream is unavailable (caller serves stale cache).
|
|
async fn refetch_metadata(state: &Arc<AppState>, path: &str, key: &str) -> Option<Vec<u8>> {
|
|
let proxy_url = state.config.npm.proxy.as_ref()?;
|
|
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
|
|
|
let data = proxy_fetch(
|
|
&state.http_client,
|
|
&url,
|
|
state.config.npm.proxy_timeout,
|
|
state.config.npm.proxy_auth.as_deref(),
|
|
)
|
|
.await
|
|
.ok()?;
|
|
|
|
let nora_base = nora_base_url(state);
|
|
let rewritten = rewrite_tarball_urls(&data, &nora_base, proxy_url).unwrap_or(data);
|
|
|
|
let storage = state.storage.clone();
|
|
let key_clone = key.to_string();
|
|
let cache_data = rewritten.clone();
|
|
tokio::spawn(async move {
|
|
let _ = storage.put(&key_clone, &cache_data).await;
|
|
});
|
|
|
|
Some(rewritten)
|
|
}
|
|
|
|
// ============================================================================
|
|
// npm publish
|
|
// ============================================================================
|
|
|
|
/// Validate attachment filename: only safe characters, no path traversal.
|
|
fn is_valid_attachment_name(name: &str) -> bool {
|
|
!name.is_empty()
|
|
&& !name.contains("..")
|
|
&& !name.contains('/')
|
|
&& !name.contains('\\')
|
|
&& !name.contains('\0')
|
|
&& name
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@'))
|
|
}
|
|
|
|
async fn handle_publish(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(path): Path<String>,
|
|
body: Bytes,
|
|
) -> Response {
|
|
let package_name = path;
|
|
|
|
let payload: serde_json::Value = match serde_json::from_slice(&body) {
|
|
Ok(v) => v,
|
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
|
};
|
|
|
|
// Security: verify payload name matches URL path
|
|
if let Some(payload_name) = payload.get("name").and_then(|n| n.as_str()) {
|
|
if payload_name != package_name {
|
|
tracing::warn!(
|
|
url_name = %package_name,
|
|
payload_name = %payload_name,
|
|
"SECURITY: npm publish name mismatch — possible spoofing attempt"
|
|
);
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
"Package name in URL does not match payload",
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let attachments = match payload.get("_attachments").and_then(|a| a.as_object()) {
|
|
Some(a) => a,
|
|
None => return (StatusCode::BAD_REQUEST, "Missing _attachments").into_response(),
|
|
};
|
|
|
|
let new_versions = match payload.get("versions").and_then(|v| v.as_object()) {
|
|
Some(v) => v,
|
|
None => return (StatusCode::BAD_REQUEST, "Missing versions").into_response(),
|
|
};
|
|
|
|
// Load or create metadata
|
|
let metadata_key = format!("npm/{}/metadata.json", package_name);
|
|
let mut metadata = if let Ok(existing) = state.storage.get(&metadata_key).await {
|
|
serde_json::from_slice::<serde_json::Value>(&existing)
|
|
.unwrap_or_else(|_| serde_json::json!({}))
|
|
} else {
|
|
serde_json::json!({})
|
|
};
|
|
|
|
// Version immutability
|
|
if let Some(existing_versions) = metadata.get("versions").and_then(|v| v.as_object()) {
|
|
for ver in new_versions.keys() {
|
|
if existing_versions.contains_key(ver) {
|
|
return (
|
|
StatusCode::CONFLICT,
|
|
format!("Version {} already exists", ver),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store tarballs
|
|
for (filename, attachment_data) in attachments {
|
|
if !is_valid_attachment_name(filename) {
|
|
tracing::warn!(
|
|
filename = %filename,
|
|
package = %package_name,
|
|
"SECURITY: npm publish rejected — invalid attachment filename"
|
|
);
|
|
return (StatusCode::BAD_REQUEST, "Invalid attachment filename").into_response();
|
|
}
|
|
|
|
let base64_data = match attachment_data.get("data").and_then(|d| d.as_str()) {
|
|
Some(d) => d,
|
|
None => continue,
|
|
};
|
|
|
|
let tarball_bytes = match base64::engine::general_purpose::STANDARD.decode(base64_data) {
|
|
Ok(b) => b,
|
|
Err(_) => {
|
|
return (StatusCode::BAD_REQUEST, "Invalid base64 in attachment").into_response()
|
|
}
|
|
};
|
|
|
|
let tarball_key = format!("npm/{}/tarballs/{}", package_name, filename);
|
|
if state
|
|
.storage
|
|
.put(&tarball_key, &tarball_bytes)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
}
|
|
|
|
// Store sha256
|
|
let hash = hex::encode(sha2::Sha256::digest(&tarball_bytes));
|
|
let hash_key = format!("{}.sha256", tarball_key);
|
|
let _ = state.storage.put(&hash_key, hash.as_bytes()).await;
|
|
}
|
|
|
|
// Merge versions
|
|
let Some(meta_obj) = metadata.as_object_mut() else {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "invalid metadata format").into_response();
|
|
};
|
|
let stored_versions = meta_obj.entry("versions").or_insert(serde_json::json!({}));
|
|
if let Some(sv) = stored_versions.as_object_mut() {
|
|
for (ver, ver_data) in new_versions {
|
|
sv.insert(ver.clone(), ver_data.clone());
|
|
}
|
|
}
|
|
|
|
// Copy standard fields
|
|
for field in &["name", "_id", "description", "readme", "license"] {
|
|
if let Some(val) = payload.get(*field) {
|
|
meta_obj.insert(field.to_string(), val.clone());
|
|
}
|
|
}
|
|
|
|
// Merge dist-tags
|
|
if let Some(new_dist_tags) = payload.get("dist-tags").and_then(|d| d.as_object()) {
|
|
let stored_dist_tags = meta_obj.entry("dist-tags").or_insert(serde_json::json!({}));
|
|
if let Some(sdt) = stored_dist_tags.as_object_mut() {
|
|
for (tag, ver) in new_dist_tags {
|
|
sdt.insert(tag.clone(), ver.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rewrite tarball URLs for published packages
|
|
let nora_base = nora_base_url(&state);
|
|
if let Some(versions) = metadata.get_mut("versions").and_then(|v| v.as_object_mut()) {
|
|
for (ver, ver_data) in versions.iter_mut() {
|
|
if let Some(dist) = ver_data.get_mut("dist") {
|
|
let short_name = package_name.split('/').next_back().unwrap_or(&package_name);
|
|
let tarball_url = format!(
|
|
"{}/npm/{}/-/{}-{}.tgz",
|
|
nora_base.trim_end_matches('/'),
|
|
package_name,
|
|
short_name,
|
|
ver
|
|
);
|
|
dist["tarball"] = serde_json::Value::String(tarball_url);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store metadata
|
|
match serde_json::to_vec(&metadata) {
|
|
Ok(bytes) => {
|
|
if state.storage.put(&metadata_key, &bytes).await.is_err() {
|
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
}
|
|
}
|
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
}
|
|
|
|
state.metrics.record_upload("npm");
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::Push,
|
|
package_name,
|
|
"npm",
|
|
"LOCAL",
|
|
));
|
|
state
|
|
.audit
|
|
.log(AuditEntry::new("push", "api", "", "npm", ""));
|
|
state.repo_index.invalidate("npm");
|
|
|
|
StatusCode::CREATED.into_response()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
fn with_content_type(
|
|
is_tarball: bool,
|
|
data: Bytes,
|
|
) -> (StatusCode, [(header::HeaderName, &'static str); 1], Bytes) {
|
|
let content_type = if is_tarball {
|
|
"application/octet-stream"
|
|
} else {
|
|
"application/json"
|
|
};
|
|
|
|
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_rewrite_tarball_urls_regular_package() {
|
|
let metadata = serde_json::json!({
|
|
"name": "lodash",
|
|
"versions": {
|
|
"4.17.21": {
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
"shasum": "abc123"
|
|
}
|
|
}
|
|
}
|
|
});
|
|
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["versions"]["4.17.21"]["dist"]["tarball"],
|
|
"http://nora:5000/npm/lodash/-/lodash-4.17.21.tgz"
|
|
);
|
|
assert_eq!(json["versions"]["4.17.21"]["dist"]["shasum"], "abc123");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_tarball_urls_scoped_package() {
|
|
let metadata = serde_json::json!({
|
|
"name": "@babel/core",
|
|
"versions": {
|
|
"7.26.0": {
|
|
"dist": {
|
|
"tarball": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
|
|
"integrity": "sha512-test"
|
|
}
|
|
}
|
|
}
|
|
});
|
|
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["versions"]["7.26.0"]["dist"]["tarball"],
|
|
"http://nora:5000/npm/@babel/core/-/core-7.26.0.tgz"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_tarball_urls_multiple_versions() {
|
|
let metadata = serde_json::json!({
|
|
"name": "express",
|
|
"versions": {
|
|
"4.18.2": { "dist": { "tarball": "https://registry.npmjs.org/express/-/express-4.18.2.tgz" } },
|
|
"4.19.0": { "dist": { "tarball": "https://registry.npmjs.org/express/-/express-4.19.0.tgz" } }
|
|
}
|
|
});
|
|
let data = serde_json::to_vec(&metadata).unwrap();
|
|
let result = rewrite_tarball_urls(
|
|
&data,
|
|
"https://demo.getnora.io",
|
|
"https://registry.npmjs.org",
|
|
)
|
|
.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
|
|
|
|
assert_eq!(
|
|
json["versions"]["4.18.2"]["dist"]["tarball"],
|
|
"https://demo.getnora.io/npm/express/-/express-4.18.2.tgz"
|
|
);
|
|
assert_eq!(
|
|
json["versions"]["4.19.0"]["dist"]["tarball"],
|
|
"https://demo.getnora.io/npm/express/-/express-4.19.0.tgz"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_tarball_urls_no_versions() {
|
|
let metadata = serde_json::json!({ "name": "empty-pkg" });
|
|
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["name"], "empty-pkg");
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_invalid_json() {
|
|
assert!(rewrite_tarball_urls(
|
|
b"not json",
|
|
"http://nora:5000",
|
|
"https://registry.npmjs.org"
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_attachment_names() {
|
|
assert!(is_valid_attachment_name("lodash-4.17.21.tgz"));
|
|
assert!(is_valid_attachment_name("core-7.26.0.tgz"));
|
|
assert!(is_valid_attachment_name("my_package-1.0.0.tgz"));
|
|
assert!(is_valid_attachment_name("@scope-pkg-1.0.0.tgz"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_traversal_attachment_names() {
|
|
assert!(!is_valid_attachment_name("../../etc/passwd"));
|
|
assert!(!is_valid_attachment_name(
|
|
"../docker/nginx/manifests/latest.json"
|
|
));
|
|
assert!(!is_valid_attachment_name("foo/bar.tgz"));
|
|
assert!(!is_valid_attachment_name("foo\\bar.tgz"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_and_null_attachment_names() {
|
|
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);
|
|
}
|
|
}
|