feat: Cargo sparse index (RFC 2789) + PyPI twine upload + PEP 691 (#113)

Cargo registry:
- Sparse index with config.json, prefix-based lookup (1/2/3/4+ char rules)
- cargo publish wire format (LE u32 lengths + JSON metadata + tarball)
- Version immutability with Cargo-compatible JSON error responses
- Dependency field mapping (version_req->req, explicit_name_in_toml->package)
- Case-insensitive crate name normalization across all endpoints
- Cache-Control headers on index (max-age=300) and downloads (immutable)

PyPI registry:
- twine upload via multipart/form-data with SHA-256 verification
- PEP 691 JSON API with Accept header content negotiation
- Hash fragment preservation in proxied links (PEP 503)
- Package name normalization per PEP 503

577 tests (up from 504), 0 failures, clippy clean.
This commit is contained in:
2026-04-08 09:38:18 +03:00
committed by GitHub
parent 25ba9f6cb5
commit 27a368b3a0
5 changed files with 1508 additions and 90 deletions

View File

@@ -1,9 +1,28 @@
# Changelog
## [Unreleased]
## [0.5.0] - 2026-04-07
### Added
- **Cargo sparse index (RFC 2789)** — cargo can now use NORA as a proper registry with `sparse+http://` protocol, including `config.json`, prefix-based index lookup, and `cargo publish` wire format support
- **Cargo publish** — full publish flow with wire format parsing, version immutability (409 Conflict), SHA-256 checksums in sparse index, and proper `warnings` response format
- **PyPI twine upload** — `twine upload` via multipart/form-data with SHA-256 verification, filename validation, and version immutability
- **PEP 691 JSON API** — content negotiation via `Accept: application/vnd.pypi.simple.v1+json` for package index and version listing, with hash digests in responses
- 577 total tests (up from 504), including 25 new Cargo tests and 18 new PyPI tests
### Fixed
- Go and Raw registries missing from Prometheus metrics (`detect_registry` labeled both as "other")
- Go and Raw registries missing from `/health` endpoint `registries` object
- Cargo dependency field mapping: `version_req` correctly renamed to `req` and `explicit_name_in_toml` to `package` in sparse index entries, matching Cargo registry specification
- Cargo crate names normalized to lowercase across all endpoints (publish, download, metadata, sparse index) for consistent storage keys
- Cargo publish write ordering: index written before .crate tarball to prevent orphaned files on partial failure
- Cargo conflict errors now return Cargo-compatible JSON format (`{"errors": [{"detail": "..."}]}`)
- PyPI hash fragments preserved when rewriting upstream links (PEP 503 compliance)
- Redundant path traversal checks removed from crate name validation (charset already excludes unsafe characters)
### Changed
- Cargo sparse index and config.json responses include `Cache-Control: public, max-age=300`
- Cargo .crate downloads include `Cache-Control: public, max-age=31536000, immutable` and `Content-Type: application/x-tar`
- axum upgraded with `multipart` feature for PyPI upload support
## [0.4.0] - 2026-04-05

35
Cargo.lock generated
View File

@@ -176,6 +176,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
@@ -678,6 +679,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1500,6 +1510,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "nonempty"
version = "0.7.0"
@@ -1522,7 +1549,7 @@ dependencies = [
[[package]]
name = "nora-registry"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"argon2",
"async-trait",
@@ -2369,6 +2396,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "spinning_top"
version = "0.3.0"

View File

@@ -6,7 +6,7 @@ members = [
]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2021"
rust-version = "1.75"
license = "MIT"
@@ -16,7 +16,7 @@ homepage = "https://getnora.io"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
axum = { version = "0.8", features = ["multipart"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,47 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! PyPI registry — PEP 503 (Simple HTML) + PEP 691 (JSON) + twine upload.
//!
//! Implements:
//! GET /simple/ — package index (HTML or JSON)
//! GET /simple/{name}/ — package versions (HTML or JSON)
//! GET /simple/{name}/{filename} — download file
//! POST /simple/ — twine upload (multipart/form-data)
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::registry::{proxy_fetch, proxy_fetch_text};
use crate::AppState;
use axum::{
extract::{Path, State},
http::{header, StatusCode},
extract::{Multipart, Path, State},
http::{header, HeaderMap, StatusCode},
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use sha2::Digest;
use std::sync::Arc;
/// PEP 691 JSON content type
const PEP691_JSON: &str = "application/vnd.pypi.simple.v1+json";
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/simple/", get(list_packages))
.route("/simple/", get(list_packages).post(upload))
.route("/simple/{name}/", get(package_versions))
.route("/simple/{name}/{filename}", get(download_file))
}
/// List all packages (Simple API index)
async fn list_packages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
// ============================================================================
// Package index
// ============================================================================
/// GET /simple/ — list all packages (PEP 503 HTML or PEP 691 JSON).
async fn list_packages(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> impl IntoResponse {
let keys = state.storage.list("pypi/").await;
let mut packages = std::collections::HashSet::new();
@@ -34,52 +53,77 @@ async fn list_packages(State(state): State<Arc<AppState>>) -> impl IntoResponse
}
}
let mut html = String::from(
"<!DOCTYPE html>\n<html><head><title>Simple Index</title></head><body><h1>Simple Index</h1>\n",
);
let mut pkg_list: Vec<_> = packages.into_iter().collect();
pkg_list.sort();
for pkg in pkg_list {
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>\n", pkg, pkg));
if wants_json(&headers) {
// PEP 691 JSON response
let projects: Vec<serde_json::Value> = pkg_list
.iter()
.map(|name| serde_json::json!({"name": name}))
.collect();
let body = serde_json::json!({
"meta": {"api-version": "1.0"},
"projects": projects,
});
(
StatusCode::OK,
[(header::CONTENT_TYPE, PEP691_JSON)],
serde_json::to_string(&body).unwrap_or_default(),
)
.into_response()
} else {
// PEP 503 HTML
let mut html = String::from(
"<!DOCTYPE html>\n<html><head><title>Simple Index</title></head><body><h1>Simple Index</h1>\n",
);
for pkg in pkg_list {
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>\n", pkg, pkg));
}
html.push_str("</body></html>");
(StatusCode::OK, Html(html)).into_response()
}
html.push_str("</body></html>");
(StatusCode::OK, Html(html))
}
/// List versions/files for a specific package
// ============================================================================
// Package versions
// ============================================================================
/// GET /simple/{name}/ — list files for a package (PEP 503 HTML or PEP 691 JSON).
async fn package_versions(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
headers: HeaderMap,
) -> Response {
// Normalize package name (PEP 503)
let normalized = normalize_name(&name);
// Try to get local files first
let prefix = format!("pypi/{}/", normalized);
let keys = state.storage.list(&prefix).await;
if !keys.is_empty() {
// We have local files
let mut html = format!(
"<!DOCTYPE html>\n<html><head><title>Links for {}</title></head><body><h1>Links for {}</h1>\n",
name, name
);
for key in &keys {
if let Some(filename) = key.strip_prefix(&prefix) {
if !filename.is_empty() {
html.push_str(&format!(
"<a href=\"/simple/{}/{}\">{}</a><br>\n",
normalized, filename, filename
));
}
// Collect files with their hashes
let mut files: Vec<FileEntry> = Vec::new();
for key in &keys {
if let Some(filename) = key.strip_prefix(&prefix) {
if !filename.is_empty() && !filename.ends_with(".sha256") {
let sha256 = state
.storage
.get(&format!("{}.sha256", key))
.await
.ok()
.and_then(|d| String::from_utf8(d.to_vec()).ok());
files.push(FileEntry {
filename: filename.to_string(),
sha256,
});
}
}
html.push_str("</body></html>");
}
return (StatusCode::OK, Html(html)).into_response();
if !files.is_empty() {
return if wants_json(&headers) {
versions_json_response(&normalized, &files)
} else {
versions_html_response(&normalized, &files)
};
}
// Try proxy if configured
@@ -95,7 +139,6 @@ async fn package_versions(
)
.await
{
// Rewrite URLs in the HTML to point to our registry
let rewritten = rewrite_pypi_links(&html, &normalized);
return (StatusCode::OK, Html(rewritten)).into_response();
}
@@ -104,7 +147,11 @@ async fn package_versions(
StatusCode::NOT_FOUND.into_response()
}
/// Download a specific file
// ============================================================================
// Download
// ============================================================================
/// GET /simple/{name}/{filename} — download a specific file.
async fn download_file(
State(state): State<Arc<AppState>>,
Path((name, filename)): Path<(String, String)>,
@@ -126,20 +173,12 @@ async fn download_file(
.audit
.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
let content_type = if filename.ends_with(".whl") {
"application/zip"
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
"application/gzip"
} else {
"application/octet-stream"
};
let content_type = pypi_content_type(&filename);
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response();
}
// Try proxy if configured
if let Some(proxy_url) = &state.config.pypi.proxy {
// First, fetch the package page to find the actual download URL
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) = proxy_fetch_text(
@@ -151,7 +190,6 @@ async fn download_file(
)
.await
{
// Find the URL for this specific file
if let Some(file_url) = find_file_url(&html, &filename) {
if let Ok(data) = proxy_fetch(
&state.http_client,
@@ -173,24 +211,21 @@ async fn download_file(
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
// Cache in local storage
// Cache in background + compute hash
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;
let hash = hex::encode(sha2::Sha256::digest(&data_clone));
let _ = storage
.put(&format!("{}.sha256", key_clone), hash.as_bytes())
.await;
});
state.repo_index.invalidate("pypi");
let content_type = if filename.ends_with(".whl") {
"application/zip"
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
"application/gzip"
} else {
"application/octet-stream"
};
let content_type = pypi_content_type(&filename);
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
.into_response();
}
@@ -201,14 +236,238 @@ async fn download_file(
StatusCode::NOT_FOUND.into_response()
}
/// Normalize package name according to PEP 503
// ============================================================================
// Twine upload (PEP 503 — POST /simple/)
// ============================================================================
/// POST /simple/ — upload a package via twine.
///
/// twine sends multipart/form-data with fields:
/// :action = "file_upload"
/// name = package name
/// version = package version
/// filetype = "sdist" | "bdist_wheel"
/// content = the file bytes
/// sha256_digest = hex SHA-256 of file (optional)
/// metadata_version, summary, etc. (optional metadata)
async fn upload(State(state): State<Arc<AppState>>, mut multipart: Multipart) -> Response {
let mut action = String::new();
let mut name = String::new();
let mut version = String::new();
let mut filename = String::new();
let mut file_data: Option<Vec<u8>> = None;
let mut sha256_digest = String::new();
// Parse multipart fields
while let Ok(Some(field)) = multipart.next_field().await {
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
":action" => {
action = field.text().await.ok().unwrap_or_default();
}
"name" => {
name = field.text().await.ok().unwrap_or_default();
}
"version" => {
version = field.text().await.ok().unwrap_or_default();
}
"sha256_digest" => {
sha256_digest = field.text().await.ok().unwrap_or_default();
}
"content" => {
filename = field.file_name().unwrap_or("unknown").to_string();
match field.bytes().await {
Ok(b) => file_data = Some(b.to_vec()),
Err(e) => {
return (
StatusCode::BAD_REQUEST,
format!("Failed to read file: {}", e),
)
.into_response()
}
}
}
_ => {
// Skip other metadata fields (summary, author, etc.)
let _ = field.bytes().await;
}
}
}
// Validate required fields
if action != "file_upload" {
return (StatusCode::BAD_REQUEST, "Unsupported action").into_response();
}
if name.is_empty() || version.is_empty() {
return (StatusCode::BAD_REQUEST, "Missing name or version").into_response();
}
let data = match file_data {
Some(d) if !d.is_empty() => d,
_ => return (StatusCode::BAD_REQUEST, "Missing file content").into_response(),
};
// Validate filename
if filename.is_empty() || !is_valid_pypi_filename(&filename) {
return (StatusCode::BAD_REQUEST, "Invalid filename").into_response();
}
// Verify SHA-256 if provided
let computed_hash = hex::encode(sha2::Sha256::digest(&data));
if !sha256_digest.is_empty() && sha256_digest != computed_hash {
tracing::warn!(
package = %name,
expected = %sha256_digest,
computed = %computed_hash,
"SECURITY: PyPI upload SHA-256 mismatch"
);
return (StatusCode::BAD_REQUEST, "SHA-256 digest mismatch").into_response();
}
// Normalize name and store
let normalized = normalize_name(&name);
// Check immutability (same filename = already exists)
let file_key = format!("pypi/{}/{}", normalized, filename);
if state.storage.stat(&file_key).await.is_some() {
return (
StatusCode::CONFLICT,
format!("File {} already exists", filename),
)
.into_response();
}
// Store file
if state.storage.put(&file_key, &data).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
// Store SHA-256 hash
let hash_key = format!("{}.sha256", file_key);
let _ = state.storage.put(&hash_key, computed_hash.as_bytes()).await;
state.metrics.record_upload("pypi");
state.activity.push(ActivityEntry::new(
ActionType::Push,
format!("{}-{}", name, version),
"pypi",
"LOCAL",
));
state
.audit
.log(AuditEntry::new("push", "api", "", "pypi", ""));
state.repo_index.invalidate("pypi");
StatusCode::OK.into_response()
}
// ============================================================================
// PEP 691 JSON responses
// ============================================================================
struct FileEntry {
filename: String,
sha256: Option<String>,
}
fn versions_json_response(normalized: &str, files: &[FileEntry]) -> Response {
let file_entries: Vec<serde_json::Value> = files
.iter()
.map(|f| {
let mut entry = serde_json::json!({
"filename": f.filename,
"url": format!("/simple/{}/{}", normalized, f.filename),
});
if let Some(hash) = &f.sha256 {
entry["digests"] = serde_json::json!({"sha256": hash});
}
entry
})
.collect();
let body = serde_json::json!({
"meta": {"api-version": "1.0"},
"name": normalized,
"files": file_entries,
});
(
StatusCode::OK,
[(header::CONTENT_TYPE, PEP691_JSON)],
serde_json::to_string(&body).unwrap_or_default(),
)
.into_response()
}
fn versions_html_response(normalized: &str, files: &[FileEntry]) -> Response {
let mut html = format!(
"<!DOCTYPE html>\n<html><head><title>Links for {}</title></head><body><h1>Links for {}</h1>\n",
normalized, normalized
);
for f in files {
let hash_fragment = f
.sha256
.as_ref()
.map(|h| format!("#sha256={}", h))
.unwrap_or_default();
html.push_str(&format!(
"<a href=\"/simple/{}/{}{}\">{}</a><br>\n",
normalized, f.filename, hash_fragment, f.filename
));
}
html.push_str("</body></html>");
(StatusCode::OK, Html(html)).into_response()
}
// ============================================================================
// Helpers
// ============================================================================
/// Normalize package name according to PEP 503.
fn normalize_name(name: &str) -> String {
name.to_lowercase().replace(['-', '_', '.'], "-")
}
/// Rewrite PyPI links to point to our registry
/// Check Accept header for PEP 691 JSON.
fn wants_json(headers: &HeaderMap) -> bool {
headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|v| v.contains(PEP691_JSON))
.unwrap_or(false)
}
/// Content-type for PyPI files.
fn pypi_content_type(filename: &str) -> &'static str {
if filename.ends_with(".whl") {
"application/zip"
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
"application/gzip"
} else {
"application/octet-stream"
}
}
/// Validate PyPI filename.
fn is_valid_pypi_filename(name: &str) -> bool {
!name.is_empty()
&& !name.contains("..")
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains('\0')
&& (name.ends_with(".tar.gz")
|| name.ends_with(".tgz")
|| name.ends_with(".whl")
|| name.ends_with(".zip")
|| name.ends_with(".egg"))
}
/// Rewrite PyPI links to point to our registry.
fn rewrite_pypi_links(html: &str, package_name: &str) -> String {
// Simple regex-free approach: find href="..." and rewrite
let mut result = String::with_capacity(html.len());
let mut remaining = html;
@@ -219,10 +478,13 @@ fn rewrite_pypi_links(html: &str, package_name: &str) -> String {
if let Some(href_end) = remaining.find('"') {
let url = &remaining[..href_end];
// Extract filename from URL
if let Some(filename) = extract_filename(url) {
// Rewrite to our local URL
result.push_str(&format!("/simple/{}/{}", package_name, filename));
// Extract hash fragment from original URL
let hash_fragment = url.find('#').map(|pos| &url[pos..]).unwrap_or("");
result.push_str(&format!(
"/simple/{}/{}{}",
package_name, filename, hash_fragment
));
} else {
result.push_str(url);
}
@@ -233,12 +495,11 @@ fn rewrite_pypi_links(html: &str, package_name: &str) -> String {
result.push_str(remaining);
// Remove data-core-metadata and data-dist-info-metadata attributes
// as we don't serve .metadata files (PEP 658)
let result = remove_attribute(&result, "data-core-metadata");
remove_attribute(&result, "data-dist-info-metadata")
}
/// Remove an HTML attribute from all tags
/// Remove an HTML attribute from all tags.
fn remove_attribute(html: &str, attr_name: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut remaining = html;
@@ -248,7 +509,6 @@ fn remove_attribute(html: &str, attr_name: &str) -> String {
result.push_str(&remaining[..attr_start]);
remaining = &remaining[attr_start + pattern.len()..];
// Skip the attribute value
if let Some(attr_end) = remaining.find('"') {
remaining = &remaining[attr_end + 1..];
}
@@ -257,19 +517,11 @@ fn remove_attribute(html: &str, attr_name: &str) -> String {
result
}
/// Extract filename from PyPI download URL
/// Extract filename from PyPI download URL.
fn extract_filename(url: &str) -> Option<&str> {
// PyPI URLs look like:
// https://files.pythonhosted.org/packages/.../package-1.0.0.tar.gz#sha256=...
// or just the filename directly
// Remove hash fragment
let url = url.split('#').next()?;
// Get the last path component
let filename = url.rsplit('/').next()?;
// Must be a valid package file
if filename.ends_with(".tar.gz")
|| filename.ends_with(".tgz")
|| filename.ends_with(".whl")
@@ -282,7 +534,7 @@ fn extract_filename(url: &str) -> Option<&str> {
}
}
/// Find the download URL for a specific file in the HTML
/// Find the download URL for a specific file in the HTML.
fn find_file_url(html: &str, target_filename: &str) -> Option<String> {
let mut remaining = html;
@@ -294,7 +546,6 @@ fn find_file_url(html: &str, target_filename: &str) -> Option<String> {
if let Some(filename) = extract_filename(url) {
if filename == target_filename {
// Remove hash fragment for actual download
return Some(url.split('#').next().unwrap_or(url).to_string());
}
}
@@ -306,6 +557,10 @@ fn find_file_url(html: &str, target_filename: &str) -> Option<String> {
None
}
// ============================================================================
// Unit Tests
// ============================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
@@ -481,7 +736,14 @@ mod tests {
fn test_rewrite_pypi_links_basic() {
let html = r#"<a href="https://files.pythonhosted.org/packages/aa/bb/flask-2.0.tar.gz#sha256=abc">flask-2.0.tar.gz</a>"#;
let result = rewrite_pypi_links(html, "flask");
assert!(result.contains("/simple/flask/flask-2.0.tar.gz"));
assert!(result.contains("/simple/flask/flask-2.0.tar.gz#sha256=abc"));
}
#[test]
fn test_rewrite_pypi_links_preserves_hash() {
let html = r#"<a href="https://example.com/pkg-1.0.whl#sha256=deadbeef">pkg</a>"#;
let result = rewrite_pypi_links(html, "pkg");
assert!(result.contains("#sha256=deadbeef"));
}
#[test]
@@ -527,12 +789,50 @@ mod tests {
let result = find_file_url(html, "pkg-1.0.whl");
assert_eq!(result, Some("https://example.com/pkg-1.0.whl".to_string()));
}
#[test]
fn test_is_valid_pypi_filename() {
assert!(is_valid_pypi_filename("flask-2.0.tar.gz"));
assert!(is_valid_pypi_filename("flask-2.0-py3-none-any.whl"));
assert!(is_valid_pypi_filename("flask-2.0.tgz"));
assert!(is_valid_pypi_filename("flask-2.0.zip"));
assert!(is_valid_pypi_filename("flask-2.0.egg"));
assert!(!is_valid_pypi_filename(""));
assert!(!is_valid_pypi_filename("../evil.tar.gz"));
assert!(!is_valid_pypi_filename("evil/path.tar.gz"));
assert!(!is_valid_pypi_filename("noext"));
assert!(!is_valid_pypi_filename("bad.exe"));
}
#[test]
fn test_wants_json_pep691() {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT, PEP691_JSON.parse().unwrap());
assert!(wants_json(&headers));
}
#[test]
fn test_wants_json_html() {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT, "text/html".parse().unwrap());
assert!(!wants_json(&headers));
}
#[test]
fn test_wants_json_no_header() {
let headers = HeaderMap::new();
assert!(!wants_json(&headers));
}
}
// ============================================================================
// Integration Tests
// ============================================================================
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod integration_tests {
use crate::test_helpers::{body_bytes, create_test_context, send};
use crate::test_helpers::{body_bytes, create_test_context, send, send_with_headers};
use axum::http::{Method, StatusCode};
#[tokio::test]
@@ -550,7 +850,6 @@ mod integration_tests {
async fn test_pypi_list_with_packages() {
let ctx = create_test_context();
// Pre-populate storage with a package
ctx.state
.storage
.put("pypi/flask/flask-2.0.tar.gz", b"fake-tarball-data")
@@ -565,11 +864,36 @@ mod integration_tests {
assert!(html.contains("flask"));
}
#[tokio::test]
async fn test_pypi_list_json_pep691() {
let ctx = create_test_context();
ctx.state
.storage
.put("pypi/flask/flask-2.0.tar.gz", b"data")
.await
.unwrap();
let response = send_with_headers(
&ctx.app,
Method::GET,
"/simple/",
vec![("Accept", "application/vnd.pypi.simple.v1+json")],
"",
)
.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!(json["meta"]["api-version"].as_str() == Some("1.0"));
assert!(json["projects"].as_array().unwrap().len() == 1);
}
#[tokio::test]
async fn test_pypi_versions_local() {
let ctx = create_test_context();
// Pre-populate storage
ctx.state
.storage
.put("pypi/flask/flask-2.0.tar.gz", b"fake-data")
@@ -585,6 +909,65 @@ mod integration_tests {
assert!(html.contains("/simple/flask/flask-2.0.tar.gz"));
}
#[tokio::test]
async fn test_pypi_versions_with_hash() {
let ctx = create_test_context();
ctx.state
.storage
.put("pypi/flask/flask-2.0.tar.gz", b"fake-data")
.await
.unwrap();
ctx.state
.storage
.put(
"pypi/flask/flask-2.0.tar.gz.sha256",
b"abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
)
.await
.unwrap();
let response = send(&ctx.app, Method::GET, "/simple/flask/", "").await;
assert_eq!(response.status(), StatusCode::OK);
let body = body_bytes(response).await;
let html = String::from_utf8_lossy(&body);
assert!(html.contains("#sha256=abc123"));
}
#[tokio::test]
async fn test_pypi_versions_json_pep691() {
let ctx = create_test_context();
ctx.state
.storage
.put("pypi/flask/flask-2.0.tar.gz", b"data")
.await
.unwrap();
ctx.state
.storage
.put("pypi/flask/flask-2.0.tar.gz.sha256", b"deadbeef")
.await
.unwrap();
let response = send_with_headers(
&ctx.app,
Method::GET,
"/simple/flask/",
vec![("Accept", "application/vnd.pypi.simple.v1+json")],
"",
)
.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"], "flask");
assert!(json["files"].as_array().unwrap().len() == 1);
assert_eq!(json["files"][0]["filename"], "flask-2.0.tar.gz");
assert_eq!(json["files"][0]["digests"]["sha256"], "deadbeef");
}
#[tokio::test]
async fn test_pypi_download_local() {
let ctx = create_test_context();
@@ -607,7 +990,6 @@ mod integration_tests {
async fn test_pypi_not_found_no_proxy() {
let ctx = create_test_context();
// No proxy configured, no local data
let response = send(&ctx.app, Method::GET, "/simple/nonexistent/", "").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);