mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 06:50:31 +00:00
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:
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,9 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
- Go and Raw registries missing from Prometheus metrics (`detect_registry` labeled both as "other")
|
- 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
|
||||||
- Go and Raw registries missing from `/health` endpoint `registries` object
|
- 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
|
## [0.4.0] - 2026-04-05
|
||||||
|
|
||||||
|
|||||||
35
Cargo.lock
generated
35
Cargo.lock
generated
@@ -176,6 +176,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -678,6 +679,15 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1500,6 +1510,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nonempty"
|
name = "nonempty"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1522,7 +1549,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-registry"
|
name = "nora-registry"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -2369,6 +2396,12 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "spinning_top"
|
name = "spinning_top"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -16,7 +16,7 @@ homepage = "https://getnora.io"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
axum = "0.8"
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,47 @@
|
|||||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
// SPDX-License-Identifier: MIT
|
// 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::activity_log::{ActionType, ActivityEntry};
|
||||||
use crate::audit::AuditEntry;
|
use crate::audit::AuditEntry;
|
||||||
use crate::registry::{proxy_fetch, proxy_fetch_text};
|
use crate::registry::{proxy_fetch, proxy_fetch_text};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Multipart, Path, State},
|
||||||
http::{header, StatusCode},
|
http::{header, HeaderMap, StatusCode},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use sha2::Digest;
|
||||||
use std::sync::Arc;
|
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>> {
|
pub fn routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/simple/", get(list_packages))
|
.route("/simple/", get(list_packages).post(upload))
|
||||||
.route("/simple/{name}/", get(package_versions))
|
.route("/simple/{name}/", get(package_versions))
|
||||||
.route("/simple/{name}/{filename}", get(download_file))
|
.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 keys = state.storage.list("pypi/").await;
|
||||||
let mut packages = std::collections::HashSet::new();
|
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();
|
let mut pkg_list: Vec<_> = packages.into_iter().collect();
|
||||||
pkg_list.sort();
|
pkg_list.sort();
|
||||||
|
|
||||||
for pkg in pkg_list {
|
if wants_json(&headers) {
|
||||||
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>\n", pkg, pkg));
|
// 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(
|
async fn package_versions(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// Normalize package name (PEP 503)
|
|
||||||
let normalized = normalize_name(&name);
|
let normalized = normalize_name(&name);
|
||||||
|
|
||||||
// Try to get local files first
|
|
||||||
let prefix = format!("pypi/{}/", normalized);
|
let prefix = format!("pypi/{}/", normalized);
|
||||||
let keys = state.storage.list(&prefix).await;
|
let keys = state.storage.list(&prefix).await;
|
||||||
|
|
||||||
if !keys.is_empty() {
|
// Collect files with their hashes
|
||||||
// We have local files
|
let mut files: Vec<FileEntry> = Vec::new();
|
||||||
let mut html = format!(
|
for key in &keys {
|
||||||
"<!DOCTYPE html>\n<html><head><title>Links for {}</title></head><body><h1>Links for {}</h1>\n",
|
if let Some(filename) = key.strip_prefix(&prefix) {
|
||||||
name, name
|
if !filename.is_empty() && !filename.ends_with(".sha256") {
|
||||||
);
|
let sha256 = state
|
||||||
|
.storage
|
||||||
for key in &keys {
|
.get(&format!("{}.sha256", key))
|
||||||
if let Some(filename) = key.strip_prefix(&prefix) {
|
.await
|
||||||
if !filename.is_empty() {
|
.ok()
|
||||||
html.push_str(&format!(
|
.and_then(|d| String::from_utf8(d.to_vec()).ok());
|
||||||
"<a href=\"/simple/{}/{}\">{}</a><br>\n",
|
files.push(FileEntry {
|
||||||
normalized, filename, filename
|
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
|
// Try proxy if configured
|
||||||
@@ -95,7 +139,6 @@ async fn package_versions(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// Rewrite URLs in the HTML to point to our registry
|
|
||||||
let rewritten = rewrite_pypi_links(&html, &normalized);
|
let rewritten = rewrite_pypi_links(&html, &normalized);
|
||||||
return (StatusCode::OK, Html(rewritten)).into_response();
|
return (StatusCode::OK, Html(rewritten)).into_response();
|
||||||
}
|
}
|
||||||
@@ -104,7 +147,11 @@ async fn package_versions(
|
|||||||
StatusCode::NOT_FOUND.into_response()
|
StatusCode::NOT_FOUND.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a specific file
|
// ============================================================================
|
||||||
|
// Download
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// GET /simple/{name}/{filename} — download a specific file.
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path((name, filename)): Path<(String, String)>,
|
Path((name, filename)): Path<(String, String)>,
|
||||||
@@ -126,20 +173,12 @@ async fn download_file(
|
|||||||
.audit
|
.audit
|
||||||
.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
|
.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
|
||||||
|
|
||||||
let content_type = if filename.ends_with(".whl") {
|
let content_type = pypi_content_type(&filename);
|
||||||
"application/zip"
|
|
||||||
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
|
||||||
"application/gzip"
|
|
||||||
} else {
|
|
||||||
"application/octet-stream"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response();
|
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try proxy if configured
|
// Try proxy if configured
|
||||||
if let Some(proxy_url) = &state.config.pypi.proxy {
|
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);
|
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||||
|
|
||||||
if let Ok(html) = proxy_fetch_text(
|
if let Ok(html) = proxy_fetch_text(
|
||||||
@@ -151,7 +190,6 @@ async fn download_file(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// Find the URL for this specific file
|
|
||||||
if let Some(file_url) = find_file_url(&html, &filename) {
|
if let Some(file_url) = find_file_url(&html, &filename) {
|
||||||
if let Ok(data) = proxy_fetch(
|
if let Ok(data) = proxy_fetch(
|
||||||
&state.http_client,
|
&state.http_client,
|
||||||
@@ -173,24 +211,21 @@ async fn download_file(
|
|||||||
.audit
|
.audit
|
||||||
.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
|
.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
|
||||||
|
|
||||||
// Cache in local storage
|
// Cache in background + compute hash
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
let key_clone = key.clone();
|
let key_clone = key.clone();
|
||||||
let data_clone = data.clone();
|
let data_clone = data.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = storage.put(&key_clone, &data_clone).await;
|
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");
|
state.repo_index.invalidate("pypi");
|
||||||
|
|
||||||
let content_type = if filename.ends_with(".whl") {
|
let content_type = pypi_content_type(&filename);
|
||||||
"application/zip"
|
|
||||||
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
|
||||||
"application/gzip"
|
|
||||||
} else {
|
|
||||||
"application/octet-stream"
|
|
||||||
};
|
|
||||||
|
|
||||||
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -201,14 +236,238 @@ async fn download_file(
|
|||||||
StatusCode::NOT_FOUND.into_response()
|
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 {
|
fn normalize_name(name: &str) -> String {
|
||||||
name.to_lowercase().replace(['-', '_', '.'], "-")
|
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 {
|
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 result = String::with_capacity(html.len());
|
||||||
let mut remaining = html;
|
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('"') {
|
if let Some(href_end) = remaining.find('"') {
|
||||||
let url = &remaining[..href_end];
|
let url = &remaining[..href_end];
|
||||||
|
|
||||||
// Extract filename from URL
|
|
||||||
if let Some(filename) = extract_filename(url) {
|
if let Some(filename) = extract_filename(url) {
|
||||||
// Rewrite to our local URL
|
// Extract hash fragment from original URL
|
||||||
result.push_str(&format!("/simple/{}/{}", package_name, filename));
|
let hash_fragment = url.find('#').map(|pos| &url[pos..]).unwrap_or("");
|
||||||
|
result.push_str(&format!(
|
||||||
|
"/simple/{}/{}{}",
|
||||||
|
package_name, filename, hash_fragment
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
result.push_str(url);
|
result.push_str(url);
|
||||||
}
|
}
|
||||||
@@ -233,12 +495,11 @@ fn rewrite_pypi_links(html: &str, package_name: &str) -> String {
|
|||||||
result.push_str(remaining);
|
result.push_str(remaining);
|
||||||
|
|
||||||
// Remove data-core-metadata and data-dist-info-metadata attributes
|
// 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");
|
let result = remove_attribute(&result, "data-core-metadata");
|
||||||
remove_attribute(&result, "data-dist-info-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 {
|
fn remove_attribute(html: &str, attr_name: &str) -> String {
|
||||||
let mut result = String::with_capacity(html.len());
|
let mut result = String::with_capacity(html.len());
|
||||||
let mut remaining = html;
|
let mut remaining = html;
|
||||||
@@ -248,7 +509,6 @@ fn remove_attribute(html: &str, attr_name: &str) -> String {
|
|||||||
result.push_str(&remaining[..attr_start]);
|
result.push_str(&remaining[..attr_start]);
|
||||||
remaining = &remaining[attr_start + pattern.len()..];
|
remaining = &remaining[attr_start + pattern.len()..];
|
||||||
|
|
||||||
// Skip the attribute value
|
|
||||||
if let Some(attr_end) = remaining.find('"') {
|
if let Some(attr_end) = remaining.find('"') {
|
||||||
remaining = &remaining[attr_end + 1..];
|
remaining = &remaining[attr_end + 1..];
|
||||||
}
|
}
|
||||||
@@ -257,19 +517,11 @@ fn remove_attribute(html: &str, attr_name: &str) -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract filename from PyPI download URL
|
/// Extract filename from PyPI download URL.
|
||||||
fn extract_filename(url: &str) -> Option<&str> {
|
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()?;
|
let url = url.split('#').next()?;
|
||||||
|
|
||||||
// Get the last path component
|
|
||||||
let filename = url.rsplit('/').next()?;
|
let filename = url.rsplit('/').next()?;
|
||||||
|
|
||||||
// Must be a valid package file
|
|
||||||
if filename.ends_with(".tar.gz")
|
if filename.ends_with(".tar.gz")
|
||||||
|| filename.ends_with(".tgz")
|
|| filename.ends_with(".tgz")
|
||||||
|| filename.ends_with(".whl")
|
|| 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> {
|
fn find_file_url(html: &str, target_filename: &str) -> Option<String> {
|
||||||
let mut remaining = html;
|
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 let Some(filename) = extract_filename(url) {
|
||||||
if filename == target_filename {
|
if filename == target_filename {
|
||||||
// Remove hash fragment for actual download
|
|
||||||
return Some(url.split('#').next().unwrap_or(url).to_string());
|
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
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unit Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -481,7 +736,14 @@ mod tests {
|
|||||||
fn test_rewrite_pypi_links_basic() {
|
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 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");
|
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]
|
#[test]
|
||||||
@@ -527,12 +789,50 @@ mod tests {
|
|||||||
let result = find_file_url(html, "pkg-1.0.whl");
|
let result = find_file_url(html, "pkg-1.0.whl");
|
||||||
assert_eq!(result, Some("https://example.com/pkg-1.0.whl".to_string()));
|
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)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
mod integration_tests {
|
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};
|
use axum::http::{Method, StatusCode};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -550,7 +850,6 @@ mod integration_tests {
|
|||||||
async fn test_pypi_list_with_packages() {
|
async fn test_pypi_list_with_packages() {
|
||||||
let ctx = create_test_context();
|
let ctx = create_test_context();
|
||||||
|
|
||||||
// Pre-populate storage with a package
|
|
||||||
ctx.state
|
ctx.state
|
||||||
.storage
|
.storage
|
||||||
.put("pypi/flask/flask-2.0.tar.gz", b"fake-tarball-data")
|
.put("pypi/flask/flask-2.0.tar.gz", b"fake-tarball-data")
|
||||||
@@ -565,11 +864,36 @@ mod integration_tests {
|
|||||||
assert!(html.contains("flask"));
|
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]
|
#[tokio::test]
|
||||||
async fn test_pypi_versions_local() {
|
async fn test_pypi_versions_local() {
|
||||||
let ctx = create_test_context();
|
let ctx = create_test_context();
|
||||||
|
|
||||||
// Pre-populate storage
|
|
||||||
ctx.state
|
ctx.state
|
||||||
.storage
|
.storage
|
||||||
.put("pypi/flask/flask-2.0.tar.gz", b"fake-data")
|
.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"));
|
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]
|
#[tokio::test]
|
||||||
async fn test_pypi_download_local() {
|
async fn test_pypi_download_local() {
|
||||||
let ctx = create_test_context();
|
let ctx = create_test_context();
|
||||||
@@ -607,7 +990,6 @@ mod integration_tests {
|
|||||||
async fn test_pypi_not_found_no_proxy() {
|
async fn test_pypi_not_found_no_proxy() {
|
||||||
let ctx = create_test_context();
|
let ctx = create_test_context();
|
||||||
|
|
||||||
// No proxy configured, no local data
|
|
||||||
let response = send(&ctx.app, Method::GET, "/simple/nonexistent/", "").await;
|
let response = send(&ctx.app, Method::GET, "/simple/nonexistent/", "").await;
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|||||||
Reference in New Issue
Block a user