Files
nora/nora-registry/src/registry/cargo_registry.rs
DevITWay | Pavel Volkov e4168b7ee4 chore: add workspace clippy lints, release profiles, COMPAT.md, diff-registry.sh (#119)
- Workspace clippy lints: or_fun_call, redundant_clone, collection_is_never_read,
  naive_bytecount, stable_sort_primitive, large_types_passed_by_value, assigning_clones
- Fix or_fun_call in cargo_registry.rs (unwrap_or -> unwrap_or_else)
- Release profiles: release (thin LTO) + release-official (full LTO, codegen-units=1)
- COMPAT.md: protocol compatibility matrix for all 7 registries (40 endpoints)
- scripts/diff-registry.sh: differential smoke tests (Docker/npm/Cargo/PyPI/Go/Raw)
2026-04-09 12:38:59 +03:00

1200 lines
38 KiB
Rust

// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Cargo registry with sparse index (RFC 2789).
//!
//! Implements:
//! GET /cargo/index/config.json — registry configuration
//! GET /cargo/index/{prefix}/{crate} — sparse index entries
//! GET /cargo/api/v1/crates/{crate_name} — crate metadata (proxy)
//! GET /cargo/api/v1/crates/{name}/{ver}/download — download .crate
//! PUT /cargo/api/v1/crates/new — cargo publish
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::registry::proxy_fetch;
use crate::validation::validate_storage_key;
use crate::AppState;
use axum::{
body::Bytes,
extract::{Path, State},
http::{header, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::{get, put},
Router,
};
use sha2::Digest;
use std::sync::Arc;
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/cargo/index/config.json", get(index_config))
.route("/cargo/index/{*path}", get(sparse_index))
.route("/cargo/api/v1/crates/{crate_name}", get(get_metadata))
.route(
"/cargo/api/v1/crates/{crate_name}/{version}/download",
get(download),
)
.route("/cargo/api/v1/crates/new", put(publish))
}
// ============================================================================
// Sparse index — RFC 2789
// ============================================================================
/// GET /cargo/index/config.json — tells cargo where to download crates.
async fn index_config(State(state): State<Arc<AppState>>) -> Response {
let base = nora_base_url(&state);
let config = serde_json::json!({
"dl": format!("{}/cargo/api/v1/crates", base.trim_end_matches('/')),
"api": format!("{}/cargo", base.trim_end_matches('/'))
});
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=300"),
),
],
serde_json::to_vec(&config).unwrap_or_default(),
)
.into_response()
}
/// GET /cargo/index/{prefix}/{crate} — sparse index lookup.
///
/// Cargo sparse index uses a directory structure based on crate name length:
/// 1 char: /cargo/index/1/{name}
/// 2 chars: /cargo/index/2/{name}
/// 3 chars: /cargo/index/3/{first_char}/{name}
/// 4+ chars: /cargo/index/{first_two}/{next_two}/{name}
///
/// Each entry is one JSON line per version (newline-delimited).
async fn sparse_index(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
// Extract crate name from the path (last segment), normalized to lowercase
let crate_name = match path.rsplit('/').next() {
Some(name) if !name.is_empty() => name.to_lowercase(),
_ => return StatusCode::NOT_FOUND.into_response(),
};
// Validate crate name
if !is_valid_crate_name(&crate_name) {
return StatusCode::BAD_REQUEST.into_response();
}
// Verify prefix matches the crate name (case-insensitive)
let expected_prefix = crate_index_prefix(&crate_name);
if path.to_lowercase() != format!("{}/{}", expected_prefix, crate_name) {
return StatusCode::NOT_FOUND.into_response();
}
// Try local index first
let index_key = format!("cargo/index/{}/{}", expected_prefix, crate_name);
if let Ok(data) = state.storage.get(&index_key).await {
state.metrics.record_download("cargo");
state.metrics.record_cache_hit();
state.activity.push(ActivityEntry::new(
ActionType::CacheHit,
crate_name.to_string(),
"cargo",
"CACHE",
));
return sparse_index_response(data.to_vec());
}
// Try upstream sparse index (sparse+https://index.crates.io/)
let proxy_url = match &state.config.cargo.proxy {
Some(url) => url.clone(),
None => return StatusCode::NOT_FOUND.into_response(),
};
// crates.io sparse index lives at index.crates.io
let upstream_index_url = if proxy_url.contains("crates.io") {
format!("https://index.crates.io/{}/{}", expected_prefix, crate_name)
} else {
// Custom registry: assume sparse index at {proxy}/index/{prefix}/{crate}
format!(
"{}/index/{}/{}",
proxy_url.trim_end_matches('/'),
expected_prefix,
crate_name
)
};
match proxy_fetch(
&state.http_client,
&upstream_index_url,
state.config.cargo.proxy_timeout,
state.config.cargo.proxy_auth.as_deref(),
)
.await
{
Ok(data) => {
state.metrics.record_download("cargo");
state.metrics.record_cache_miss();
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
crate_name.to_string(),
"cargo",
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "cargo", ""));
// Cache in background
let storage = state.storage.clone();
let key = index_key;
let data_clone = data.clone();
tokio::spawn(async move {
let _ = storage.put(&key, &data_clone).await;
});
state.repo_index.invalidate("cargo");
sparse_index_response(data)
}
Err(crate::registry::ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::debug!(
crate_name = crate_name,
error = ?e,
"Cargo sparse index upstream error"
);
StatusCode::NOT_FOUND.into_response()
}
}
}
// ============================================================================
// Metadata & download (existing, refactored)
// ============================================================================
/// GET /cargo/api/v1/crates/{crate_name} — JSON metadata.
async fn get_metadata(
State(state): State<Arc<AppState>>,
Path(crate_name): Path<String>,
) -> Response {
if validate_storage_key(&crate_name).is_err() {
return StatusCode::BAD_REQUEST.into_response();
}
let crate_name = crate_name.to_lowercase();
let key = format!("cargo/{}/metadata.json", crate_name);
if let Ok(data) = state.storage.get(&key).await {
return (StatusCode::OK, data).into_response();
}
// Proxy fetch metadata from upstream
let proxy_url = match &state.config.cargo.proxy {
Some(url) => url.clone(),
None => return StatusCode::NOT_FOUND.into_response(),
};
let url = format!(
"{}/api/v1/crates/{}",
proxy_url.trim_end_matches('/'),
crate_name
);
match proxy_fetch(
&state.http_client,
&url,
state.config.cargo.proxy_timeout,
state.config.cargo.proxy_auth.as_deref(),
)
.await
{
Ok(data) => {
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;
});
(StatusCode::OK, data).into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
/// GET /cargo/api/v1/crates/{name}/{version}/download — download .crate file.
async fn download(
State(state): State<Arc<AppState>>,
Path((crate_name, version)): Path<(String, String)>,
) -> Response {
if validate_storage_key(&crate_name).is_err() || validate_storage_key(&version).is_err() {
return StatusCode::BAD_REQUEST.into_response();
}
let crate_name = crate_name.to_lowercase();
let key = format!(
"cargo/{}/{}/{}-{}.crate",
crate_name, version, crate_name, version
);
// Try local storage first
if let Ok(data) = state.storage.get(&key).await {
state.metrics.record_download("cargo");
state.metrics.record_cache_hit();
state.activity.push(ActivityEntry::new(
ActionType::Pull,
format!("{}@{}", crate_name, version),
"cargo",
"LOCAL",
));
state
.audit
.log(AuditEntry::new("pull", "api", "", "cargo", ""));
return (
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-tar"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
),
],
data,
)
.into_response();
}
// Proxy fetch from upstream
let proxy_url = match &state.config.cargo.proxy {
Some(url) => url.clone(),
None => return StatusCode::NOT_FOUND.into_response(),
};
let url = format!(
"{}/api/v1/crates/{}/{}/download",
proxy_url.trim_end_matches('/'),
crate_name,
version
);
match proxy_fetch(
&state.http_client,
&url,
state.config.cargo.proxy_timeout,
state.config.cargo.proxy_auth.as_deref(),
)
.await
{
Ok(data) => {
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.metrics.record_download("cargo");
state.metrics.record_cache_miss();
state.activity.push(ActivityEntry::new(
ActionType::Pull,
format!("{}@{}", crate_name, version),
"cargo",
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "cargo", ""));
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-tar"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
),
],
data,
)
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
// ============================================================================
// Cargo publish
// ============================================================================
/// PUT /cargo/api/v1/crates/new — publish a crate.
///
/// Wire format (cargo puts this as the body):
/// 4 bytes LE: metadata JSON length
/// N bytes: metadata JSON
/// 4 bytes LE: .crate tarball length
/// M bytes: .crate tarball
async fn publish(State(state): State<Arc<AppState>>, body: Bytes) -> Response {
if body.len() < 8 {
return (StatusCode::BAD_REQUEST, "Payload too small").into_response();
}
// Parse wire format
let metadata_len = u32::from_le_bytes([body[0], body[1], body[2], body[3]]) as usize;
if body.len() < 4 + metadata_len + 4 {
return (StatusCode::BAD_REQUEST, "Truncated metadata").into_response();
}
let metadata_bytes = &body[4..4 + metadata_len];
let metadata: serde_json::Value = match serde_json::from_slice(metadata_bytes) {
Ok(v) => v,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
format!("Invalid metadata JSON: {}", e),
)
.into_response()
}
};
let crate_len_offset = 4 + metadata_len;
let crate_len = u32::from_le_bytes([
body[crate_len_offset],
body[crate_len_offset + 1],
body[crate_len_offset + 2],
body[crate_len_offset + 3],
]) as usize;
let crate_start = crate_len_offset + 4;
if body.len() < crate_start + crate_len {
return (StatusCode::BAD_REQUEST, "Truncated crate tarball").into_response();
}
let crate_data = &body[crate_start..crate_start + crate_len];
// Extract required fields
let name = match metadata.get("name").and_then(|n| n.as_str()) {
Some(n) => n,
None => return (StatusCode::BAD_REQUEST, "Missing crate name").into_response(),
};
let vers = match metadata.get("vers").and_then(|v| v.as_str()) {
Some(v) => v,
None => return (StatusCode::BAD_REQUEST, "Missing crate version").into_response(),
};
// Validate
if !is_valid_crate_name(name) {
return (StatusCode::BAD_REQUEST, "Invalid crate name").into_response();
}
if validate_storage_key(vers).is_err() {
return (StatusCode::BAD_REQUEST, "Invalid version").into_response();
}
// Normalize to lowercase for consistent storage keys
let name = name.to_lowercase();
let vers = vers.to_string();
// Check version immutability
let crate_key = format!("cargo/{}/{}/{}-{}.crate", name, vers, name, vers);
if state.storage.stat(&crate_key).await.is_some() {
let err = serde_json::json!({
"errors": [{"detail": format!("crate version `{}@{}` already exists", name, vers)}]
});
return (
StatusCode::CONFLICT,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)],
serde_json::to_vec(&err).unwrap_or_default(),
)
.into_response();
}
// Compute checksum
let cksum = hex::encode(sha2::Sha256::digest(crate_data));
// Build sparse index entry (one JSON line per version)
// Transform deps: Cargo publish sends `version_req` but index format requires `req`,
// and `explicit_name_in_toml` becomes `package` in the index.
let deps = metadata
.get("deps")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.map(|dep| {
let mut d = dep.clone();
if let Some(obj) = d.as_object_mut() {
// version_req -> req
if let Some(vr) = obj.remove("version_req") {
obj.insert("req".to_string(), vr);
}
// explicit_name_in_toml -> package
if let Some(ent) = obj.remove("explicit_name_in_toml") {
if !ent.is_null() {
obj.insert("package".to_string(), ent);
}
}
}
d
})
.collect::<Vec<_>>()
})
.map(serde_json::Value::Array)
.unwrap_or(serde_json::json!([]));
let features = metadata
.get("features")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let features2 = metadata.get("features2").cloned();
let links = metadata.get("links").cloned();
let mut index_entry = serde_json::json!({
"name": name,
"vers": vers,
"deps": deps,
"cksum": cksum,
"features": features,
"yanked": false,
});
if let Some(f2) = features2 {
index_entry["features2"] = f2;
}
if let Some(l) = links {
index_entry["links"] = l;
}
let entry_line = serde_json::to_string(&index_entry).unwrap_or_default();
// Write index FIRST — if it fails, no orphaned .crate file
// If .crate write fails later, re-publish is possible (immutability checks .crate, not index)
let prefix = crate_index_prefix(&name);
let index_key = format!("cargo/index/{}/{}", prefix, name);
let mut index_content = state
.storage
.get(&index_key)
.await
.map(|d| d.to_vec())
.unwrap_or_default();
// Ensure newline separator
if !index_content.is_empty() && !index_content.ends_with(b"\n") {
index_content.push(b'\n');
}
index_content.extend_from_slice(entry_line.as_bytes());
index_content.push(b'\n');
if state.storage.put(&index_key, &index_content).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
// Store .crate tarball SECOND
if state.storage.put(&crate_key, crate_data).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
state.metrics.record_upload("cargo");
state.activity.push(ActivityEntry::new(
ActionType::Push,
format!("{}@{}", name, vers),
"cargo",
"LOCAL",
));
state
.audit
.log(AuditEntry::new("push", "api", "", "cargo", ""));
state.repo_index.invalidate("cargo");
// Cargo expects a JSON response with warnings array
let response = serde_json::json!({
"warnings": {
"invalid_categories": [],
"invalid_badges": [],
"other": []
}
});
(
StatusCode::OK,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)],
serde_json::to_vec(&response).unwrap_or_default(),
)
.into_response()
}
// ============================================================================
// Helpers
// ============================================================================
/// Compute sparse index prefix for a crate name (RFC 2789).
fn crate_index_prefix(name: &str) -> String {
let lower = name.to_lowercase();
match lower.len() {
1 => "1".to_string(),
2 => "2".to_string(),
3 => format!("3/{}", &lower[..1]),
_ => format!("{}/{}", &lower[..2], &lower[2..4]),
}
}
/// Validate crate name per Cargo spec.
fn is_valid_crate_name(name: &str) -> bool {
if name.is_empty() || name.len() > 64 {
return false;
}
// Must start with alphanumeric
let first = name.chars().next().unwrap_or('\0');
if !first.is_ascii_alphanumeric() {
return false;
}
// Only alphanumeric, `-`, `_`
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// Construct NORA base URL from config.
fn nora_base_url(state: &AppState) -> String {
if let Some(url) = &state.config.server.public_url {
return url.clone();
}
format!(
"http://{}:{}",
state.config.server.host, state.config.server.port
)
}
/// Build response with sparse index content-type.
fn sparse_index_response(data: Vec<u8>) -> Response {
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=300"),
),
],
data,
)
.into_response()
}
// ============================================================================
// Unit Tests
// ============================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
// ── Prefix computation (RFC 2789) ───────────────────────────────────
#[test]
fn test_prefix_single_char() {
assert_eq!(crate_index_prefix("a"), "1");
assert_eq!(crate_index_prefix("Z"), "1");
}
#[test]
fn test_prefix_two_chars() {
assert_eq!(crate_index_prefix("ab"), "2");
assert_eq!(crate_index_prefix("IO"), "2");
}
#[test]
fn test_prefix_three_chars() {
assert_eq!(crate_index_prefix("abc"), "3/a");
assert_eq!(crate_index_prefix("Foo"), "3/f");
}
#[test]
fn test_prefix_four_plus_chars() {
assert_eq!(crate_index_prefix("serde"), "se/rd");
assert_eq!(crate_index_prefix("tokio"), "to/ki");
assert_eq!(crate_index_prefix("Axum"), "ax/um");
assert_eq!(crate_index_prefix("ab_cd_ef"), "ab/_c");
}
// ── Crate name validation ───────────────────────────────────────────
#[test]
fn test_valid_crate_names() {
assert!(is_valid_crate_name("serde"));
assert!(is_valid_crate_name("my-crate"));
assert!(is_valid_crate_name("my_crate"));
assert!(is_valid_crate_name("a"));
assert!(is_valid_crate_name("crate123"));
}
#[test]
fn test_invalid_crate_names() {
assert!(!is_valid_crate_name(""));
assert!(!is_valid_crate_name("-start"));
assert!(!is_valid_crate_name("_start"));
assert!(!is_valid_crate_name("has space"));
assert!(!is_valid_crate_name("has/slash"));
assert!(!is_valid_crate_name("has..dots"));
assert!(!is_valid_crate_name("has\\backslash"));
assert!(!is_valid_crate_name(&"a".repeat(65)));
}
#[test]
fn test_crate_name_max_length() {
assert!(is_valid_crate_name(&"a".repeat(64)));
assert!(!is_valid_crate_name(&"a".repeat(65)));
}
}
// ============================================================================
// Integration Tests
// ============================================================================
#[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};
#[tokio::test]
async fn test_cargo_index_config() {
let ctx = create_test_context();
let resp = send(&ctx.app, Method::GET, "/cargo/index/config.json", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("dl").is_some());
assert!(json.get("api").is_some());
}
#[tokio::test]
async fn test_cargo_sparse_index_from_storage() {
let ctx = create_test_context();
let index_data = br#"{"name":"serde","vers":"1.0.0","deps":[],"cksum":"abc123","features":{},"yanked":false}"#;
ctx.state
.storage
.put("cargo/index/se/rd/serde", index_data)
.await
.unwrap();
let resp = send(&ctx.app, Method::GET, "/cargo/index/se/rd/serde", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
assert_eq!(&body[..], index_data);
}
#[tokio::test]
async fn test_cargo_sparse_index_wrong_prefix() {
let ctx = create_test_context();
// "serde" should be at se/rd/serde, not 1/serde
let resp = send(&ctx.app, Method::GET, "/cargo/index/1/serde", "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_cargo_sparse_index_single_char() {
let ctx = create_test_context();
ctx.state
.storage
.put("cargo/index/1/a", b"index-data")
.await
.unwrap();
let resp = send(&ctx.app, Method::GET, "/cargo/index/1/a", "").await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_cargo_sparse_index_two_char() {
let ctx = create_test_context();
ctx.state
.storage
.put("cargo/index/2/ab", b"index-data")
.await
.unwrap();
let resp = send(&ctx.app, Method::GET, "/cargo/index/2/ab", "").await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_cargo_sparse_index_three_char() {
let ctx = create_test_context();
ctx.state
.storage
.put("cargo/index/3/f/foo", b"index-data")
.await
.unwrap();
let resp = send(&ctx.app, Method::GET, "/cargo/index/3/f/foo", "").await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_cargo_sparse_index_not_found_no_proxy() {
let ctx = create_test_context();
let resp = send(&ctx.app, Method::GET, "/cargo/index/se/rd/serde", "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_cargo_metadata_not_found() {
let ctx = create_test_context();
let resp = send(
&ctx.app,
Method::GET,
"/cargo/api/v1/crates/nonexistent",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_cargo_metadata_from_storage() {
let ctx = create_test_context();
let meta = r#"{"name":"test-crate","versions":[]}"#;
ctx.state
.storage
.put("cargo/test-crate/metadata.json", meta.as_bytes())
.await
.unwrap();
let resp = send(&ctx.app, Method::GET, "/cargo/api/v1/crates/test-crate", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
assert_eq!(&body[..], meta.as_bytes());
}
#[tokio::test]
async fn test_cargo_download_not_found() {
let ctx = create_test_context();
let resp = send(
&ctx.app,
Method::GET,
"/cargo/api/v1/crates/missing/1.0.0/download",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_cargo_download_from_storage() {
let ctx = create_test_context();
ctx.state
.storage
.put("cargo/my-crate/1.2.3/my-crate-1.2.3.crate", b"crate-data")
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/cargo/api/v1/crates/my-crate/1.2.3/download",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
assert_eq!(&body[..], b"crate-data");
}
// ── Publish tests ───────────────────────────────────────────────────
/// Build cargo publish wire format: 4-byte LE metadata len + metadata + 4-byte LE crate len + crate
fn build_publish_payload(metadata: &serde_json::Value, crate_data: &[u8]) -> Vec<u8> {
let meta_bytes = serde_json::to_vec(metadata).unwrap();
let meta_len = (meta_bytes.len() as u32).to_le_bytes();
let crate_len = (crate_data.len() as u32).to_le_bytes();
let mut payload = Vec::new();
payload.extend_from_slice(&meta_len);
payload.extend_from_slice(&meta_bytes);
payload.extend_from_slice(&crate_len);
payload.extend_from_slice(crate_data);
payload
}
#[tokio::test]
async fn test_cargo_publish_basic() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "my-crate",
"vers": "0.1.0",
"deps": [],
"features": {},
});
let crate_data = b"fake-crate-tarball";
let payload = build_publish_payload(&metadata, crate_data);
let resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
// Verify .crate stored
let stored = ctx
.state
.storage
.get("cargo/my-crate/0.1.0/my-crate-0.1.0.crate")
.await
.unwrap();
assert_eq!(&stored[..], crate_data);
// Verify sparse index entry created
let index = ctx
.state
.storage
.get("cargo/index/my/-c/my-crate")
.await
.unwrap();
let index_str = String::from_utf8_lossy(&index);
assert!(index_str.contains("\"name\":\"my-crate\""));
assert!(index_str.contains("\"vers\":\"0.1.0\""));
assert!(index_str.contains("\"cksum\":"));
}
#[tokio::test]
async fn test_cargo_publish_version_immutability() {
let ctx = create_test_context();
// First publish
let metadata = serde_json::json!({
"name": "immut-test",
"vers": "1.0.0",
"deps": [],
"features": {},
});
let payload = build_publish_payload(&metadata, b"crate-v1");
let resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
// Second publish with same version → CONFLICT
let payload2 = build_publish_payload(&metadata, b"crate-v1-again");
let resp2 = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload2),
)
.await;
assert_eq!(resp2.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_cargo_publish_multiple_versions() {
let ctx = create_test_context();
// v0.1.0
let m1 =
serde_json::json!({"name": "multi-ver", "vers": "0.1.0", "deps": [], "features": {}});
let p1 = build_publish_payload(&m1, b"crate-01");
let r1 = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(p1),
)
.await;
assert_eq!(r1.status(), StatusCode::OK);
// v0.2.0
let m2 =
serde_json::json!({"name": "multi-ver", "vers": "0.2.0", "deps": [], "features": {}});
let p2 = build_publish_payload(&m2, b"crate-02");
let r2 = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(p2),
)
.await;
assert_eq!(r2.status(), StatusCode::OK);
// Index should have 2 lines
let index = ctx
.state
.storage
.get("cargo/index/mu/lt/multi-ver")
.await
.unwrap();
let index_str = String::from_utf8_lossy(&index);
let lines: Vec<&str> = index_str.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("0.1.0"));
assert!(lines[1].contains("0.2.0"));
}
#[tokio::test]
async fn test_cargo_publish_invalid_name() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "../traversal",
"vers": "1.0.0",
"deps": [],
"features": {},
});
let payload = build_publish_payload(&metadata, b"bad");
let resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_cargo_publish_truncated_payload() {
let ctx = create_test_context();
let resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(vec![0u8; 3]),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_cargo_publish_response_has_warnings() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "warn-test",
"vers": "1.0.0",
"deps": [],
"features": {},
});
let payload = build_publish_payload(&metadata, b"crate-data");
let resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("warnings").is_some());
}
#[tokio::test]
async fn test_cargo_publish_then_download() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "roundtrip",
"vers": "2.0.0",
"deps": [],
"features": {},
});
let crate_data = b"published-crate-content";
let payload = build_publish_payload(&metadata, crate_data);
// Publish
let publish_resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(publish_resp.status(), StatusCode::OK);
// Download
let dl_resp = send(
&ctx.app,
Method::GET,
"/cargo/api/v1/crates/roundtrip/2.0.0/download",
"",
)
.await;
assert_eq!(dl_resp.status(), StatusCode::OK);
let body = body_bytes(dl_resp).await;
assert_eq!(&body[..], crate_data);
}
#[tokio::test]
async fn test_cargo_publish_then_sparse_index() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "idx-test",
"vers": "1.0.0",
"deps": [{"name": "serde", "req": "^1", "features": [], "optional": false, "default_features": true, "target": null, "kind": "normal"}],
"features": {"default": ["serde"]},
"links": null,
});
let payload = build_publish_payload(&metadata, b"crate");
let publish_resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(publish_resp.status(), StatusCode::OK);
// Sparse index lookup
let idx_resp = send(&ctx.app, Method::GET, "/cargo/index/id/x-/idx-test", "").await;
assert_eq!(idx_resp.status(), StatusCode::OK);
let body = body_bytes(idx_resp).await;
let line: serde_json::Value =
serde_json::from_str(String::from_utf8_lossy(&body).lines().next().unwrap()).unwrap();
assert_eq!(line["name"], "idx-test");
assert_eq!(line["vers"], "1.0.0");
assert!(line["deps"].as_array().unwrap().len() == 1);
assert!(line["cksum"].as_str().unwrap().len() == 64); // sha256 hex
}
#[tokio::test]
async fn test_cargo_publish_transforms_deps_version_req_to_req() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "dep-test",
"vers": "1.0.0",
"deps": [{
"name": "serde",
"version_req": "^1.0",
"features": ["derive"],
"optional": false,
"default_features": true,
"target": null,
"kind": "normal",
"registry": null,
"explicit_name_in_toml": null
}, {
"name": "my_serde",
"version_req": "^1.0",
"features": [],
"optional": false,
"default_features": true,
"target": null,
"kind": "normal",
"registry": null,
"explicit_name_in_toml": "serde_json"
}],
"features": {},
});
let payload = build_publish_payload(&metadata, b"crate-data");
let resp = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
// Read the sparse index entry
let index = ctx
.state
.storage
.get("cargo/index/de/p-/dep-test")
.await
.unwrap();
let line: serde_json::Value =
serde_json::from_str(String::from_utf8_lossy(&index).lines().next().unwrap()).unwrap();
let deps = line["deps"].as_array().unwrap();
assert_eq!(deps.len(), 2);
// version_req must be renamed to req
assert!(
deps[0].get("version_req").is_none(),
"version_req should not be in index"
);
assert_eq!(deps[0]["req"], "^1.0", "version_req must be renamed to req");
// explicit_name_in_toml=null should be dropped (not become package=null)
assert!(deps[0].get("explicit_name_in_toml").is_none());
assert!(
deps[0].get("package").is_none(),
"null explicit_name_in_toml should not create package field"
);
// explicit_name_in_toml="serde_json" should become package="serde_json"
assert!(deps[1].get("explicit_name_in_toml").is_none());
assert_eq!(
deps[1]["package"], "serde_json",
"explicit_name_in_toml must become package"
);
}
#[tokio::test]
async fn test_cargo_publish_conflict_json_format() {
let ctx = create_test_context();
let metadata = serde_json::json!({
"name": "conflict-fmt",
"vers": "1.0.0",
"deps": [],
"features": {},
});
let payload = build_publish_payload(&metadata, b"v1");
let r1 = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload),
)
.await;
assert_eq!(r1.status(), StatusCode::OK);
// Second publish -> CONFLICT with Cargo JSON format
let payload2 = build_publish_payload(&metadata, b"v1-again");
let r2 = send(
&ctx.app,
Method::PUT,
"/cargo/api/v1/crates/new",
Body::from(payload2),
)
.await;
assert_eq!(r2.status(), StatusCode::CONFLICT);
let body = body_bytes(r2).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["errors"].as_array().unwrap().len() > 0);
assert!(json["errors"][0]["detail"]
.as_str()
.unwrap()
.contains("already exists"));
}
}