use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::auth::{enforce_namespace_scope, NamespaceAuthority};
use crate::registry::{method_not_allowed, proxied_repo_conflict};
use crate::validation::validate_storage_key;
use crate::AppState;
use axum::{
body::Bytes,
extract::{Path, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Extension, Router,
};
use flate2::write::GzEncoder;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Write};
pub const INDEX_PATTERN: (&str, &str) = ("deb/", ".deb");
pub fn routes() -> Router<AppState> {
Router::new()
.route("/deb/{repo}/-/reindex", axum::routing::post(reindex))
.route(
"/deb/{repo}/{*path}",
get(download)
.head(check_exists)
.put(upload)
.delete(delete_package)
.fallback(|| async { method_not_allowed("GET, HEAD, PUT, DELETE") }),
)
}
const META_DIR: &str = ".nora-meta";
const RESERVED: &[&str] = &[
"Packages",
"Packages.gz",
"Release",
"InRelease",
"Release.gpg",
];
const CONTROL_TAR_MAX: u64 = 8 * 1024 * 1024;
fn package_key(repo: &str, path: &str) -> String {
format!("deb/{repo}/{path}")
}
fn sidecar_key(repo: &str, path: &str) -> String {
format!("deb/{repo}/{META_DIR}/{path}.json")
}
fn release_key(repo: &str) -> String {
format!("deb/{repo}/Release")
}
fn strip_flat_prefix(path: &str) -> &str {
path.strip_prefix("./").unwrap_or(path)
}
fn validate_package_path(repo: &str, path: &str) -> Result<(), &'static str> {
if repo.is_empty() || !repo.is_ascii() || repo.contains('/') || repo.starts_with('.') {
return Err("Invalid repository name");
}
if !path.is_ascii() || path.contains("..") || path.contains('\0') || path.starts_with('/') {
return Err("Invalid path");
}
if !path.to_ascii_lowercase().ends_with(".deb") {
return Err("Only .deb files can be published");
}
if path
.split('/')
.any(|seg| seg.is_empty() || seg.starts_with('.'))
{
return Err("Invalid path");
}
if RESERVED.contains(&path) || path.starts_with("dists/") {
return Err("Reserved index path");
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Placement {
distribution: String,
component: String,
}
#[derive(Debug, Default, Deserialize)]
struct PublishQuery {
distribution: Option<String>,
component: Option<String>,
}
fn valid_release_token(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& !s.starts_with('.')
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
}
fn resolve_placement(q: PublishQuery) -> Result<Option<Placement>, &'static str> {
let Some(distribution) = q.distribution else {
return match q.component {
None => Ok(None),
Some(_) => Err("component requires distribution"),
};
};
let component = q.component.unwrap_or_else(|| "main".to_string());
if !valid_release_token(&distribution) || !valid_release_token(&component) {
return Err("Invalid distribution/component name");
}
Ok(Some(Placement {
distribution,
component,
}))
}
struct CappedVec {
buf: Vec<u8>,
cap: u64,
}
impl Write for CappedVec {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
if self.buf.len() as u64 + data.len() as u64 > self.cap {
return Err(std::io::Error::other("decompressed size limit exceeded"));
}
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn read_capped(mut reader: impl Read, cap: u64) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
reader
.by_ref()
.take(cap + 1)
.read_to_end(&mut out)
.map_err(|e| e.to_string())?;
if out.len() as u64 > cap {
return Err("decompressed size limit exceeded".into());
}
Ok(out)
}
fn extract_control(body: &[u8]) -> Result<String, String> {
let mut archive = ar::Archive::new(body);
let mut saw_debian_binary = false;
let mut control_tar: Option<Vec<u8>> = None;
let mut control_kind = "";
while let Some(entry) = archive.next_entry() {
let mut entry = entry.map_err(|e| format!("not an ar archive: {e}"))?;
let name = String::from_utf8_lossy(entry.header().identifier()).into_owned();
match name.as_str() {
"debian-binary" => {
let mut v = String::new();
entry
.take(16)
.read_to_string(&mut v)
.map_err(|e| e.to_string())?;
if v.trim() != "2.0" {
return Err(format!("unsupported deb format version: {}", v.trim()));
}
saw_debian_binary = true;
}
"control.tar" | "control.tar.gz" | "control.tar.xz" | "control.tar.zst" => {
let mut raw = Vec::new();
entry.read_to_end(&mut raw).map_err(|e| e.to_string())?;
control_kind = match name.as_str() {
"control.tar" => "",
"control.tar.gz" => "gz",
"control.tar.xz" => "xz",
_ => "zst",
};
control_tar = Some(raw);
}
_ => {} }
}
if !saw_debian_binary {
return Err("missing debian-binary member".into());
}
let raw = control_tar.ok_or("missing control.tar member")?;
let tar_bytes = match control_kind {
"" => raw,
"gz" => read_capped(flate2::read::GzDecoder::new(&raw[..]), CONTROL_TAR_MAX)
.map_err(|e| format!("control.tar.gz: {e}"))?,
"xz" => {
let mut out = CappedVec {
buf: Vec::new(),
cap: CONTROL_TAR_MAX,
};
lzma_rs::xz_decompress(&mut &raw[..], &mut out)
.map_err(|e| format!("control.tar.xz: {e:?}"))?;
out.buf
}
_ => {
let dec = ruzstd::decoding::StreamingDecoder::new(&raw[..])
.map_err(|e| format!("control.tar.zst: {e}"))?;
read_capped(dec, CONTROL_TAR_MAX).map_err(|e| format!("control.tar.zst: {e}"))?
}
};
let mut tar = tar::Archive::new(&tar_bytes[..]);
for entry in tar.entries().map_err(|e| format!("control.tar: {e}"))? {
let mut entry = entry.map_err(|e| format!("control.tar: {e}"))?;
let path = entry.path().map_err(|e| e.to_string())?;
if path.file_name().and_then(|n| n.to_str()) == Some("control")
&& path.components().count() <= 2
{
let mut control = String::new();
entry
.read_to_string(&mut control)
.map_err(|e| format!("control file: {e}"))?;
return Ok(control);
}
}
Err("control.tar has no control file".into())
}
fn control_field<'a>(control: &'a str, name: &str) -> Option<&'a str> {
control.lines().find_map(|l| {
let (k, v) = l.split_once(':')?;
(k == name).then(|| v.trim())
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PkgRecord {
package: String,
version: String,
arch: String,
control: String,
filename: String,
size: u64,
md5: String,
sha1: String,
sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
placement: Option<Placement>,
}
impl PkgRecord {
fn paragraph(&self) -> String {
format!(
"{}\nFilename: {}\nSize: {}\nMD5sum: {}\nSHA1: {}\nSHA256: {}\n",
self.control.trim_end(),
self.filename,
self.size,
self.md5,
self.sha1,
self.sha256
)
}
}
const COMPUTED_FIELDS: &[&str] = &["filename", "size", "md5sum", "sha1", "sha256"];
fn validate_control(control: &str) -> Result<(), String> {
for (i, line) in control.trim_end().lines().enumerate() {
if line.trim().is_empty() {
return Err("control contains a blank line (paragraph injection)".into());
}
if line.chars().any(|c| c.is_ascii_control() && c != '\t') {
return Err("control contains a control character".into());
}
if line.starts_with(' ') || line.starts_with('\t') {
if i == 0 {
return Err("control starts with a continuation line".into());
}
continue;
}
let Some((field, _)) = line.split_once(':') else {
return Err(format!(
"control line {} is not a field or continuation",
i + 1
));
};
if field.is_empty() || !field.chars().all(|c| c.is_ascii_graphic() && c != ':') {
return Err(format!("control line {} has an invalid field name", i + 1));
}
if COMPUTED_FIELDS.contains(&field.to_ascii_lowercase().as_str()) {
return Err(format!(
"control must not carry repository-computed field {field}"
));
}
}
Ok(())
}
fn extract_record(
body: &[u8],
path: &str,
placement: Option<Placement>,
) -> Result<PkgRecord, String> {
let control = extract_control(body)?;
validate_control(&control)?;
let package = control_field(&control, "Package")
.filter(|v| !v.is_empty())
.ok_or("control missing Package field")?;
let version = control_field(&control, "Version")
.filter(|v| !v.is_empty())
.ok_or("control missing Version field")?;
let arch = control_field(&control, "Architecture")
.filter(|v| !v.is_empty())
.ok_or("control missing Architecture field")?;
if placement.is_some() && !valid_release_token(arch) {
return Err(format!("Architecture {arch:?} not usable under dists/"));
}
Ok(PkgRecord {
package: package.to_string(),
version: version.to_string(),
arch: arch.to_string(),
control: control.trim_end().to_string(),
filename: path.to_string(),
size: body.len() as u64,
md5: hex::encode(md5::Md5::digest(body)),
sha1: hex::encode(sha1::Sha1::digest(body)),
sha256: hex::encode(sha2::Sha256::digest(body)),
placement,
})
}
fn release_date() -> String {
chrono::Utc::now()
.format("%a, %d %b %Y %H:%M:%S UTC")
.to_string()
}
fn hash_entry(data: &[u8], name: &str, hasher: fn(&[u8]) -> String) -> String {
format!(" {} {} {}\n", hasher(data), data.len(), name)
}
fn md5_hex(d: &[u8]) -> String {
hex::encode(md5::Md5::digest(d))
}
fn sha256_hex(d: &[u8]) -> String {
hex::encode(sha2::Sha256::digest(d))
}
fn generate_release(packages: &[u8], packages_gz: &[u8], repo: &str) -> String {
format!(
"Origin: NORA\nLabel: {repo}\nDate: {}\nMD5Sum:\n{}{}SHA256:\n{}{}",
release_date(),
hash_entry(packages, "Packages", md5_hex),
hash_entry(packages_gz, "Packages.gz", md5_hex),
hash_entry(packages, "Packages", sha256_hex),
hash_entry(packages_gz, "Packages.gz", sha256_hex),
)
}
fn generate_dist_release(
repo: &str,
dist: &str,
components: &[&str],
arches: &BTreeSet<&str>,
files: &[(String, Vec<u8>)],
) -> String {
let mut md5_lines = String::new();
let mut sha_lines = String::new();
for (name, data) in files {
md5_lines.push_str(&hash_entry(data, name, md5_hex));
sha_lines.push_str(&hash_entry(data, name, sha256_hex));
}
format!(
"Origin: NORA\nLabel: {repo}\nSuite: {dist}\nCodename: {dist}\nDate: {}\nArchitectures: {}\nComponents: {}\nMD5Sum:\n{md5_lines}SHA256:\n{sha_lines}",
release_date(),
arches.iter().copied().collect::<Vec<_>>().join(" "),
components.join(" "),
)
}
fn packages_index<'a>(pkgs: impl IntoIterator<Item = &'a PkgRecord>) -> Vec<u8> {
pkgs.into_iter()
.map(|p| p.paragraph())
.collect::<Vec<_>>()
.join("\n")
.into_bytes()
}
fn gzip(data: &[u8]) -> Result<Vec<u8>, String> {
let mut enc = GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(data)
.and_then(|_| enc.finish())
.map_err(|e| format!("gzip: {e}"))
}
fn signed_release(
signer: Option<&crate::signing::RepoSigner>,
prefix: &str,
release: &str,
) -> Result<Vec<(String, Vec<u8>)>, String> {
let mut out = vec![(format!("{prefix}Release"), release.as_bytes().to_vec())];
if let Some(signer) = signer {
out.push((
format!("{prefix}InRelease"),
signer.clearsign(release)?.into_bytes(),
));
out.push((
format!("{prefix}Release.gpg"),
signer.sign_detached(release.as_bytes())?.into_bytes(),
));
}
Ok(out)
}
pub(crate) async fn regenerate_indexes(
storage: &crate::Storage,
signer: Option<&crate::signing::RepoSigner>,
repo: &str,
) -> Result<(), String> {
let meta_prefix = format!("deb/{repo}/{META_DIR}/");
let mut pkgs: Vec<PkgRecord> = super::read_json_sidecars(storage, &meta_prefix).await?;
pkgs.sort_by(|a, b| {
(&a.package, &a.version, &a.filename).cmp(&(&b.package, &b.version, &b.filename))
});
let mut flat: Vec<&PkgRecord> = Vec::new();
let mut dists: BTreeMap<&str, BTreeMap<&str, Vec<&PkgRecord>>> = BTreeMap::new();
for p in &pkgs {
match &p.placement {
None => flat.push(p),
Some(pl) => dists
.entry(&pl.distribution)
.or_default()
.entry(&pl.component)
.or_default()
.push(p),
}
}
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
if dists.is_empty() || !flat.is_empty() {
let packages = packages_index(flat.iter().copied());
let packages_gz = gzip(&packages)?;
let release = generate_release(&packages, &packages_gz, repo);
files.push(("Packages".into(), packages));
files.push(("Packages.gz".into(), packages_gz));
files.extend(signed_release(signer, "", &release)?);
}
for (dist, comps) in &dists {
let mut arches: BTreeSet<&str> = comps
.values()
.flatten()
.map(|p| p.arch.as_str())
.filter(|a| *a != "all")
.collect();
if arches.is_empty() {
arches.insert("all");
}
let mut indexes: Vec<(String, Vec<u8>)> = Vec::new();
for (comp, cpkgs) in comps {
for arch in &arches {
let packages = packages_index(
cpkgs
.iter()
.copied()
.filter(|p| p.arch == *arch || p.arch == "all"),
);
let packages_gz = gzip(&packages)?;
indexes.push((format!("{comp}/binary-{arch}/Packages"), packages));
indexes.push((format!("{comp}/binary-{arch}/Packages.gz"), packages_gz));
}
}
let components: Vec<&str> = comps.keys().copied().collect();
let release = generate_dist_release(repo, dist, &components, &arches, &indexes);
let prefix = format!("dists/{dist}/");
files.extend(
indexes
.into_iter()
.map(|(name, data)| (format!("{prefix}{name}"), data)),
);
files.extend(signed_release(signer, &prefix, &release)?);
}
let desired: BTreeSet<&str> = files.iter().map(|(name, _)| name.as_str()).collect();
for (name, data) in &files {
storage
.put(&format!("deb/{repo}/{name}"), data)
.await
.map_err(|e| format!("write {name}: {e}"))?;
}
let repo_prefix = format!("deb/{repo}/");
for name in RESERVED {
let key = format!("{repo_prefix}{name}");
if !desired.contains(name) && storage.stat(&key).await.is_some() {
storage
.delete(&key)
.await
.map_err(|e| format!("delete stale {name}: {e}"))?;
}
}
let existing = storage
.list(&format!("{repo_prefix}dists/"))
.await
.map_err(|e| format!("list dists: {e}"))?;
for key in existing {
let stale = match key.strip_prefix(&repo_prefix) {
Some(rel) => !desired.contains(rel),
None => false,
};
if stale {
storage
.delete(&key)
.await
.map_err(|e| format!("delete stale {key}: {e}"))?;
}
}
Ok(())
}
async fn upload(
State(state): State<AppState>,
Path((repo, path)): Path<(String, String)>,
Query(query): Query<PublishQuery>,
Extension(authority): Extension<NamespaceAuthority>,
body: Bytes,
) -> Response {
if !state.config.deb.enabled {
return StatusCode::NOT_FOUND.into_response();
}
if state.config.deb.proxies.contains_key(&repo) {
return proxied_repo_conflict();
}
if let Err(msg) = validate_package_path(&repo, &path) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
let placement = match resolve_placement(query) {
Ok(p) => p,
Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
};
if enforce_namespace_scope(&authority, &repo).is_err() {
return StatusCode::FORBIDDEN.into_response();
}
if body.len() as u64 > state.config.deb.max_file_size {
return (
StatusCode::PAYLOAD_TOO_LARGE,
format!(
"File too large. Max size: {} bytes",
state.config.deb.max_file_size
),
)
.into_response();
}
let record = match extract_record(&body, &path, placement) {
Ok(r) => r,
Err(e) => {
return (StatusCode::BAD_REQUEST, format!("Not a valid deb: {e}")).into_response()
}
};
let sidecar = match serde_json::to_vec(&record) {
Ok(j) => j,
Err(e) => {
tracing::error!(error = %e, "deb: failed to serialize package record");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let key = package_key(&repo, &path);
if validate_storage_key(&key).is_err() {
return StatusCode::BAD_REQUEST.into_response();
}
let lock = state.publish_lock(&release_key(&repo));
let _guard = lock.lock().await;
if let Err(e) = state.storage.put(&key, &body).await {
tracing::error!(error = %e, key = %key, "deb: failed to store package");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
if let Err(e) = state
.storage
.put(&sidecar_key(&repo, &path), &sidecar)
.await
{
tracing::error!(error = %e, key = %key, "deb: failed to store metadata sidecar");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
if let Err(e) = regenerate_indexes(&state.storage, state.signer.as_deref(), &repo).await {
tracing::error!(repo = %repo, error = %e, "deb: index regeneration failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Package stored but index regeneration failed",
)
.into_response();
}
let nva = format!("{}_{}_{}", record.package, record.version, record.arch);
state.metrics.record_upload("deb");
state
.audit
.log(AuditEntry::new("push", "api", &nva, "deb", ""));
state.activity.push(ActivityEntry::new(
ActionType::Push,
format!("{repo}/{nva}"),
crate::registry_type::RegistryType::Deb,
"LOCAL",
));
state.repo_index.invalidate("deb");
StatusCode::CREATED.into_response()
}
async fn download(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path((repo, path)): Path<(String, String)>,
) -> Response {
if !state.config.deb.enabled {
return StatusCode::NOT_FOUND.into_response();
}
let path = strip_flat_prefix(&path).to_string();
if let Some(entry) = state.config.deb.proxies.get(&repo) {
let key = package_key(&repo, &path);
if validate_storage_key(&key).is_err() || path.starts_with(META_DIR) {
return StatusCode::BAD_REQUEST.into_response();
}
let lower = path.to_ascii_lowercase();
let immutable = lower.ends_with(".deb") || lower.ends_with(".udeb");
let url = format!("{}/{}", entry.url().trim_end_matches('/'), path);
return crate::registry::repo_proxy_download(
&state,
"deb",
crate::registry_type::RegistryType::Deb,
format!("{repo}/{path}"),
key,
url,
entry.auth(),
state.config.deb.proxy_timeout,
state.config.deb.metadata_ttl,
immutable,
content_type(&path),
)
.await;
}
if path == "pubkey.gpg" {
return match &state.signer {
Some(signer) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/pgp-keys")],
signer.public_key_armored().to_string(),
)
.into_response(),
None => StatusCode::NOT_FOUND.into_response(),
};
}
let key = package_key(&repo, &path);
if validate_storage_key(&key).is_err() || path.starts_with(META_DIR) {
return StatusCode::BAD_REQUEST.into_response();
}
let is_package = {
let lower = path.to_ascii_lowercase();
lower.ends_with(".deb") || lower.ends_with(".udeb")
};
if is_package {
if let Some(meta) = state.storage.stat(&key).await {
if let Some(response) = crate::registry::range::range_response(
&state.storage,
&[&key],
&headers,
meta.size,
content_type(&path),
&[],
)
.await
{
if response.status() == StatusCode::PARTIAL_CONTENT {
state.metrics.record_download("deb");
}
return response;
}
}
}
match state.storage.get_verified(&key).await {
Ok(outcome) => {
state.metrics.record_download("deb");
state.activity.push(ActivityEntry::new(
ActionType::Pull,
format!("{repo}/{path}"),
crate::registry_type::RegistryType::Deb,
"LOCAL",
));
use nora_registry::verified::{verified_body, GateOutcome};
let data = match outcome {
GateOutcome::Verified(blob) => verified_body(blob),
GateOutcome::Unpinned(blob) => blob.into_inner(),
};
let mut builder = axum::http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type(&path));
if is_package {
builder = builder.header(header::ACCEPT_RANGES, "bytes");
}
if RESERVED.contains(&path.as_str()) || path.starts_with("dists/") {
builder = builder.header(header::CACHE_CONTROL, "no-cache");
}
builder
.body(axum::body::Body::from(data))
.expect("valid response")
.into_response()
}
Err(crate::storage::StorageError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!(error = %e, key = %key, "deb: failed to read artifact");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
async fn check_exists(
State(state): State<AppState>,
Path((repo, path)): Path<(String, String)>,
) -> Response {
if !state.config.deb.enabled {
return StatusCode::NOT_FOUND.into_response();
}
let path = strip_flat_prefix(&path).to_string();
let key = package_key(&repo, &path);
if validate_storage_key(&key).is_err() || path.starts_with(META_DIR) {
return StatusCode::BAD_REQUEST.into_response();
}
match state.storage.stat(&key).await {
Some(meta) => (
StatusCode::OK,
[
(header::CONTENT_LENGTH, meta.size.to_string()),
(header::CONTENT_TYPE, content_type(&path).to_string()),
],
)
.into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn delete_package(
State(state): State<AppState>,
Path((repo, path)): Path<(String, String)>,
Extension(authority): Extension<NamespaceAuthority>,
) -> Response {
if !state.config.deb.enabled {
return StatusCode::NOT_FOUND.into_response();
}
if state.config.deb.proxies.contains_key(&repo) {
return proxied_repo_conflict();
}
if let Err(msg) = validate_package_path(&repo, &path) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
if enforce_namespace_scope(&authority, &repo).is_err() {
return StatusCode::FORBIDDEN.into_response();
}
let key = package_key(&repo, &path);
if validate_storage_key(&key).is_err() {
return StatusCode::BAD_REQUEST.into_response();
}
let lock = state.publish_lock(&release_key(&repo));
let _guard = lock.lock().await;
match state.storage.delete(&key).await {
Ok(()) => {}
Err(crate::storage::StorageError::NotFound) => {
return StatusCode::NOT_FOUND.into_response()
}
Err(e) => {
tracing::error!(error = %e, key = %key, "deb: failed to delete package");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
if let Err(e) = state.storage.delete(&sidecar_key(&repo, &path)).await {
tracing::warn!(error = %e, key = %key, "deb: failed to delete metadata sidecar");
}
if let Err(e) = regenerate_indexes(&state.storage, state.signer.as_deref(), &repo).await {
tracing::error!(repo = %repo, error = %e, "deb: index regeneration failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Package deleted but index regeneration failed",
)
.into_response();
}
state
.audit
.log(AuditEntry::new("delete", "api", &path, "deb", ""));
state.repo_index.invalidate("deb");
StatusCode::NO_CONTENT.into_response()
}
async fn reindex(
State(state): State<AppState>,
Path(repo): Path<String>,
Extension(authority): Extension<NamespaceAuthority>,
) -> Response {
if !state.config.deb.enabled {
return StatusCode::NOT_FOUND.into_response();
}
if repo.is_empty() || !repo.is_ascii() || repo.contains('/') || repo.starts_with('.') {
return (StatusCode::BAD_REQUEST, "Invalid repository name").into_response();
}
if state.config.deb.proxies.contains_key(&repo) {
return proxied_repo_conflict();
}
if enforce_namespace_scope(&authority, &repo).is_err() {
return StatusCode::FORBIDDEN.into_response();
}
let lock = state.publish_lock(&release_key(&repo));
let _guard = lock.lock().await;
let prefix = format!("deb/{repo}/");
let keys = match state.storage.list(&prefix).await {
Ok(k) => k,
Err(e) => {
tracing::error!(error = %e, repo = %repo, "deb reindex: list failed");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if keys.is_empty() {
return (StatusCode::NOT_FOUND, "No such repository").into_response();
}
let meta_prefix = format!("deb/{repo}/{META_DIR}/");
let mut packages: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut sidecars: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for key in &keys {
if let Some(rest) = key.strip_prefix(&meta_prefix) {
if let Some(pkg) = rest.strip_suffix(".json") {
sidecars.insert(pkg.to_string());
}
} else if let Some(rest) = key.strip_prefix(&prefix) {
if rest.to_ascii_lowercase().ends_with(".deb") {
packages.insert(rest.to_string());
}
}
}
let mut orphans_removed = 0usize;
for stale in sidecars.difference(&packages) {
match state.storage.delete(&sidecar_key(&repo, stale)).await {
Ok(()) => orphans_removed += 1,
Err(e) => {
tracing::error!(error = %e, repo = %repo, pkg = %stale, "deb reindex: orphan sidecar delete failed");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
}
let mut sidecars_created = 0usize;
for missing in packages.difference(&sidecars) {
let body = match state.storage.get(&package_key(&repo, missing)).await {
Ok(b) => b,
Err(e) => {
tracing::error!(error = %e, repo = %repo, pkg = %missing, "deb reindex: package read failed");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let record = match extract_record(&body, missing, None) {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, repo = %repo, pkg = %missing, "deb reindex: not a valid deb — refusing to index");
return (
StatusCode::UNPROCESSABLE_ENTITY,
format!("{missing} is not a valid deb: {e}"),
)
.into_response();
}
};
let json = match serde_json::to_vec(&record) {
Ok(j) => j,
Err(e) => {
tracing::error!(error = %e, "deb reindex: sidecar serialize failed");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if let Err(e) = state.storage.put(&sidecar_key(&repo, missing), &json).await {
tracing::error!(error = %e, repo = %repo, pkg = %missing, "deb reindex: sidecar write failed");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
sidecars_created += 1;
}
if let Err(e) = regenerate_indexes(&state.storage, state.signer.as_deref(), &repo).await {
tracing::error!(repo = %repo, error = %e, "deb reindex: index regeneration failed");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
state
.audit
.log(AuditEntry::new("reindex", "api", &repo, "deb", ""));
state.repo_index.invalidate("deb");
(
StatusCode::OK,
axum::Json(serde_json::json!({
"packages": packages.len(),
"sidecars_created": sidecars_created,
"orphans_removed": orphans_removed,
"signed": state.signer.is_some(),
})),
)
.into_response()
}
fn content_type(path: &str) -> &'static str {
if path.ends_with(".deb") {
"application/vnd.debian.binary-package"
} else if path.ends_with(".gz") {
"application/gzip"
} else {
"text/plain; charset=utf-8"
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_validate_package_path() {
assert!(validate_package_path("myrepo", "pool/tree_2.1.0_amd64.deb").is_ok());
assert!(validate_package_path("myrepo", "tree.deb").is_ok());
assert!(validate_package_path("a/b", "x.deb").is_err()); assert!(validate_package_path("", "x.deb").is_err());
assert!(validate_package_path(".hidden", "x.deb").is_err());
assert!(validate_package_path("myrepo", "Packages").is_err()); assert!(validate_package_path("myrepo", ".nora-meta/x.deb").is_err()); assert!(validate_package_path("myrepo", "a/../b.deb").is_err()); assert!(validate_package_path("myrepo", "/abs.deb").is_err());
assert!(validate_package_path("myrepo", "dists/jammy/x.deb").is_err()); }
#[test]
fn test_resolve_placement() {
let q = |d: Option<&str>, c: Option<&str>| PublishQuery {
distribution: d.map(String::from),
component: c.map(String::from),
};
assert!(resolve_placement(q(None, None)).unwrap().is_none());
let p = resolve_placement(q(Some("jammy"), None)).unwrap().unwrap();
assert_eq!(
(p.distribution.as_str(), p.component.as_str()),
("jammy", "main")
);
let p = resolve_placement(q(Some("bookworm-updates"), Some("contrib")))
.unwrap()
.unwrap();
assert_eq!(p.component, "contrib");
assert!(resolve_placement(q(None, Some("main"))).is_err()); for bad in ["", "a/b", ".hidden", "ja mmy", "x\u{0}y", &"a".repeat(65)] {
assert!(resolve_placement(q(Some(bad), None)).is_err(), "{bad:?}");
assert!(
resolve_placement(q(Some("jammy"), Some(bad))).is_err(),
"{bad:?}"
);
}
}
#[test]
fn test_control_field() {
let c = "Package: tree\nVersion: 2.1.0-1\nArchitecture: amd64\n";
assert_eq!(control_field(c, "Package"), Some("tree"));
assert_eq!(control_field(c, "Version"), Some("2.1.0-1"));
assert_eq!(control_field(c, "Missing"), None);
}
#[test]
fn test_paragraph_appends_computed_fields() {
let rec = PkgRecord {
package: "tree".into(),
version: "2.1.0-1".into(),
arch: "amd64".into(),
control: "Package: tree\nVersion: 2.1.0-1\nArchitecture: amd64".into(),
filename: "pool/tree.deb".into(),
size: 42,
md5: "m".into(),
sha1: "s1".into(),
sha256: "s256".into(),
placement: None,
};
let p = rec.paragraph();
assert!(p.contains("Package: tree\n"));
assert!(p.contains("Filename: pool/tree.deb\n"));
assert!(p.contains("Size: 42\n"));
assert!(p.contains("SHA256: s256\n"));
assert!(p.ends_with('\n') && !p.ends_with("\n\n"));
}
#[test]
fn test_extract_control_all_compressions() {
for comp in ["", "gz", "xz", "zst"] {
let deb = super::integration_tests::build_test_deb_with("tree", "1.0-1", comp);
let control = extract_control(&deb).unwrap_or_else(|e| panic!("{comp}: {e}"));
assert_eq!(control_field(&control, "Package"), Some("tree"), "{comp}");
}
}
#[test]
fn test_validate_control() {
let ok = "Package: tree\nVersion: 1.0-1\nArchitecture: amd64\nDescription: x\n multi-line continuation\n";
assert!(validate_control(ok).is_ok());
assert!(validate_control("Package: a\n").is_ok());
assert!(validate_control("Package: a\n\nPackage: bash\nEvil: 1").is_err());
assert!(validate_control("Package: a\n \nX: y").is_err()); for f in [
"Filename: ../x.deb",
"SHA256: 00",
"Size: 1",
"MD5sum: 0",
"sha1: 0",
] {
assert!(
validate_control(&format!("Package: a\n{f}")).is_err(),
"{f}"
);
}
assert!(validate_control("Package: a\u{0}b").is_err());
assert!(validate_control("Package: a\rb").is_err());
assert!(validate_control("Package: a\u{01}b").is_err());
assert!(validate_control(" leading continuation").is_err());
assert!(validate_control("Package: a\nnot a field line").is_err());
assert!(validate_control("Bad Field: x").is_err());
assert!(validate_control("Package: a\nX: a\tb").is_ok());
}
#[test]
fn test_extract_control_rejects_garbage_and_bombs() {
assert!(extract_control(b"not an ar archive").is_err());
let mut tarb = tar::Builder::new(Vec::new());
let mut hdr = tar::Header::new_gnu();
hdr.set_path("./control").unwrap();
hdr.set_size(32 * 1024 * 1024);
hdr.set_cksum();
tarb.append(&hdr, std::io::repeat(0).take(32 * 1024 * 1024))
.unwrap();
let tar_bytes = tarb.into_inner().unwrap();
let mut enc = GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&tar_bytes).unwrap();
let gz = enc.finish().unwrap();
let mut arb = ar::Builder::new(Vec::new());
arb.append(
&ar::Header::new(b"debian-binary".to_vec(), 4),
&b"2.0\n"[..],
)
.unwrap();
arb.append(
&ar::Header::new(b"control.tar.gz".to_vec(), gz.len() as u64),
&gz[..],
)
.unwrap();
let deb = arb.into_inner().unwrap();
let err = extract_control(&deb).unwrap_err();
assert!(err.contains("size limit"), "{err}");
}
}
#[cfg(test)]
pub(crate) mod test_fixtures {
pub(crate) fn build_deb(name: &str, version: &str) -> Vec<u8> {
super::integration_tests::build_test_deb_with(name, version, "gz")
}
pub(crate) fn build_deb_arch(name: &str, version: &str, arch: &str) -> Vec<u8> {
super::integration_tests::build_test_deb_arch(name, version, arch, "gz")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod integration_tests {
use super::RESERVED;
use crate::test_helpers::{body_bytes, create_test_context, send, send_with_headers};
use axum::http::{Method, StatusCode};
use sha2::Digest;
use std::io::Write;
pub(super) fn build_test_deb_with(name: &str, version: &str, compression: &str) -> Vec<u8> {
build_test_deb_arch(name, version, "amd64", compression)
}
pub(super) fn build_test_deb_arch(
name: &str,
version: &str,
arch: &str,
compression: &str,
) -> Vec<u8> {
let control = format!(
"Package: {name}\nVersion: {version}\nArchitecture: {arch}\nMaintainer: Test <test@example.com>\nInstalled-Size: 10\nDepends: libc6 (>= 2.34)\nSection: utils\nPriority: optional\nDescription: A test package\n built for the NORA deb registry tests\n"
);
build_test_deb_from_control(&control, compression)
}
pub(super) fn build_test_deb_from_control(control: &str, compression: &str) -> Vec<u8> {
let mut tarb = tar::Builder::new(Vec::new());
let mut hdr = tar::Header::new_gnu();
hdr.set_path("./control").unwrap();
hdr.set_size(control.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tarb.append(&hdr, control.as_bytes()).unwrap();
let tar_bytes = tarb.into_inner().unwrap();
let (member, bytes): (&str, Vec<u8>) = match compression {
"" => ("control.tar", tar_bytes),
"gz" => {
let mut enc =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&tar_bytes).unwrap();
("control.tar.gz", enc.finish().unwrap())
}
"xz" => {
let mut out = Vec::new();
lzma_rs::xz_compress(&mut &tar_bytes[..], &mut out).unwrap();
("control.tar.xz", out)
}
_ => (
"control.tar.zst",
ruzstd::encoding::compress_to_vec(
&tar_bytes[..],
ruzstd::encoding::CompressionLevel::Fastest,
),
),
};
let mut arb = ar::Builder::new(Vec::new());
arb.append(
&ar::Header::new(b"debian-binary".to_vec(), 4),
&b"2.0\n"[..],
)
.unwrap();
arb.append(
&ar::Header::new(member.as_bytes().to_vec(), bytes.len() as u64),
&bytes[..],
)
.unwrap();
let empty_tar = tar::Builder::new(Vec::new()).into_inner().unwrap();
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(&empty_tar).unwrap();
let data_gz = enc.finish().unwrap();
arb.append(
&ar::Header::new(b"data.tar.gz".to_vec(), data_gz.len() as u64),
&data_gz[..],
)
.unwrap();
arb.into_inner().unwrap()
}
fn build_test_deb(name: &str, version: &str) -> Vec<u8> {
build_test_deb_with(name, version, "zst")
}
#[tokio::test]
async fn test_deb_upload_generates_indexes() {
let ctx = create_test_context();
let body = build_test_deb("tree", "2.1.0-1");
let resp = send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/tree_2.1.0-1_amd64.deb",
body.clone(),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/Packages", "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("cache-control")
.unwrap()
.to_str()
.unwrap(),
"no-cache"
);
let packages = String::from_utf8(body_bytes(resp).await.to_vec()).unwrap();
assert!(packages.contains("Package: tree\n"), "{packages}");
assert!(packages.contains("Version: 2.1.0-1\n"));
assert!(packages.contains("Depends: libc6 (>= 2.34)\n"));
assert!(packages.contains("Filename: pool/tree_2.1.0-1_amd64.deb\n"));
assert!(packages.contains(&format!("Size: {}\n", body.len())));
let sha256 = hex::encode(sha2::Sha256::digest(&body));
assert!(packages.contains(&format!("SHA256: {sha256}\n")));
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/Packages.gz", "").await;
let gz = body_bytes(resp).await;
let mut plain = String::new();
std::io::Read::read_to_string(&mut flate2::read::GzDecoder::new(&gz[..]), &mut plain)
.unwrap();
assert_eq!(plain, packages);
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/Release", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let release = String::from_utf8(body_bytes(resp).await.to_vec()).unwrap();
let pkg_sha = hex::encode(sha2::Sha256::digest(packages.as_bytes()));
let gz_sha = hex::encode(sha2::Sha256::digest(&gz));
assert!(release.contains("SHA256:\n"), "{release}");
assert!(release.contains(&format!(" {pkg_sha} {} Packages\n", packages.len())));
assert!(release.contains(&format!(" {gz_sha} {} Packages.gz\n", gz.len())));
let resp = send(
&ctx.app,
Method::GET,
"/deb/myrepo/pool/tree_2.1.0-1_amd64.deb",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/vnd.debian.binary-package"
);
assert_eq!(&body_bytes(resp).await[..], &body[..]);
}
#[tokio::test]
async fn test_deb_multiple_packages_sorted() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/zzz.deb",
build_test_deb("zzz", "1.0"),
)
.await;
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/aaa.deb",
build_test_deb("aaa", "1.0"),
)
.await;
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/Packages", "").await;
let packages = String::from_utf8(body_bytes(resp).await.to_vec()).unwrap();
let a = packages.find("Package: aaa\n").unwrap();
let z = packages.find("Package: zzz\n").unwrap();
assert!(a < z, "paragraphs must be sorted by package name");
assert!(packages.contains("\n\nPackage: zzz\n"), "{packages}");
}
#[tokio::test]
async fn test_deb_delete_regenerates_empty_indexes() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/tree.deb",
build_test_deb("tree", "1.0"),
)
.await;
let resp = send(&ctx.app, Method::DELETE, "/deb/myrepo/tree.deb", "").await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/tree.deb", "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/Packages", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let packages = String::from_utf8(body_bytes(resp).await.to_vec()).unwrap();
assert!(!packages.contains("Package:"), "{packages}");
let resp = send(&ctx.app, Method::DELETE, "/deb/myrepo/tree.deb", "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_deb_package_range_request() {
let ctx = create_test_context();
let url = "/deb/myrepo/pool/rng_1.0_amd64.deb";
ctx.state
.storage
.put("deb/myrepo/pool/rng_1.0_amd64.deb", b"0123456789")
.await
.unwrap();
ctx.state
.storage
.put("deb/myrepo/Packages", b"Package: rng\n")
.await
.unwrap();
let resp =
send_with_headers(&ctx.app, Method::GET, url, vec![("range", "bytes=2-5")], "").await;
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
resp.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
"bytes 2-5/10"
);
assert_eq!(
resp.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
);
assert_eq!(&body_bytes(resp).await[..], b"2345");
let resp =
send_with_headers(&ctx.app, Method::GET, url, vec![("range", "bytes=10-")], "").await;
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
resp.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
"bytes */10"
);
let resp = send(&ctx.app, Method::GET, url, "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
);
let resp = send_with_headers(
&ctx.app,
Method::GET,
"/deb/myrepo/Packages",
vec![("range", "bytes=2-5")],
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().get("accept-ranges").is_none());
}
#[tokio::test]
async fn test_deb_upload_rejects_invalid() {
let ctx = create_test_context();
let resp = send(
&ctx.app,
Method::PUT,
"/deb/myrepo/x.deb",
b"garbage".to_vec(),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = send(&ctx.app, Method::PUT, "/deb/myrepo/x.txt", b"data".to_vec()).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let keys = ctx.state.storage.list("deb/").await.unwrap();
assert!(keys.is_empty(), "rejected uploads must not write: {keys:?}");
}
#[tokio::test]
async fn test_deb_upload_rejects_index_corrupting_control() {
let ctx = create_test_context();
for control in [
"Package: a\nVersion: 1\nArchitecture: amd64\n\nPackage: bash\nVersion: 999\n",
"Package: a\nVersion: 1\nArchitecture: amd64\nSHA256: 0000\n",
"Package: a\nVersion: 1\nArchitecture: amd64\nFilename: pool/other.deb\n",
] {
let deb = build_test_deb_from_control(control, "gz");
let resp = send(&ctx.app, Method::PUT, "/deb/myrepo/x.deb", deb).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{control:?}");
}
assert!(ctx.state.storage.list("deb/").await.unwrap().is_empty());
}
#[tokio::test]
async fn test_deb_max_file_size_enforced() {
let ctx =
crate::test_helpers::create_test_context_with_config(|c| c.deb.max_file_size = 16);
let resp = send(
&ctx.app,
Method::PUT,
"/deb/myrepo/big.deb",
build_test_deb("big", "1.0"),
)
.await;
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn test_deb_namespace_scope_enforced() {
use crate::config::ScopeEnforcement;
let ctx = create_test_context();
let scoped = |mode| {
crate::auth::NamespaceAuthority::from_oidc_scope("ci", &["myrepo".to_string()], mode)
};
let resp = super::upload(
axum::extract::State(ctx.state.clone()),
axum::extract::Path(("otherrepo".to_string(), "x.deb".to_string())),
axum::extract::Query(super::PublishQuery::default()),
axum::Extension(scoped(ScopeEnforcement::Enforce)),
axum::body::Bytes::from(build_test_deb("x", "1.0")),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert!(ctx.state.storage.list("deb/").await.unwrap().is_empty());
let resp = super::upload(
axum::extract::State(ctx.state.clone()),
axum::extract::Path(("myrepo".to_string(), "x.deb".to_string())),
axum::extract::Query(super::PublishQuery::default()),
axum::Extension(scoped(ScopeEnforcement::Enforce)),
axum::body::Bytes::from(build_test_deb("x", "1.0")),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = super::delete_package(
axum::extract::State(ctx.state.clone()),
axum::extract::Path(("otherrepo".to_string(), "x.deb".to_string())),
axum::Extension(scoped(ScopeEnforcement::Enforce)),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_deb_head_and_sidecars_not_served() {
let ctx = create_test_context();
let body = build_test_deb("tree", "1.0");
let len = body.len();
send(&ctx.app, Method::PUT, "/deb/myrepo/tree.deb", body).await;
let resp = send(&ctx.app, Method::HEAD, "/deb/myrepo/tree.deb", "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-length")
.unwrap()
.to_str()
.unwrap(),
len.to_string()
);
let resp = send(
&ctx.app,
Method::GET,
"/deb/myrepo/.nora-meta/tree.deb.json",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_deb_disabled_returns_404() {
let ctx = crate::test_helpers::create_test_context_with_config(|c| c.deb.enabled = false);
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/Release", "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = send(&ctx.app, Method::PUT, "/deb/myrepo/x.deb", b"x".to_vec()).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_deb_apt_dot_slash_paths_served() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/tree.deb",
build_test_deb("tree", "1.0"),
)
.await;
for path in [
"./Release",
"./Packages",
"./Packages.gz",
"./pool/tree.deb",
] {
let resp = send(&ctx.app, Method::GET, &format!("/deb/myrepo/{path}"), "").await;
assert_eq!(resp.status(), StatusCode::OK, "{path}");
let resp = send(&ctx.app, Method::HEAD, &format!("/deb/myrepo/{path}"), "").await;
assert_eq!(resp.status(), StatusCode::OK, "HEAD {path}");
}
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/./../other/Release", "").await;
assert_ne!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_deb_reserved_names_have_no_cache() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/tree.deb",
build_test_deb("tree", "1.0"),
)
.await;
for name in RESERVED
.iter()
.filter(|n| !n.starts_with("InRelease") && !n.ends_with(".gpg"))
{
let resp = send(&ctx.app, Method::GET, &format!("/deb/myrepo/{name}"), "").await;
assert_eq!(resp.status(), StatusCode::OK, "{name}");
assert_eq!(
resp.headers()
.get("cache-control")
.unwrap()
.to_str()
.unwrap(),
"no-cache",
"{name}"
);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod signing_tests {
use super::integration_tests::build_test_deb_with;
use crate::test_helpers::{
body_bytes, create_test_context, create_test_context_with_config, send,
};
use axum::http::{Method, StatusCode};
use pgp::composed::{
CleartextSignedMessage, Deserializable, DetachedSignature, SignedPublicKey,
};
#[tokio::test]
async fn test_deb_release_signatures_verify() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/sig.deb",
build_test_deb_with("sig", "1.0", "gz"),
)
.await;
let release =
body_bytes(send(&ctx.app, Method::GET, "/deb/myrepo/Release", "").await).await;
let key = body_bytes(send(&ctx.app, Method::GET, "/deb/myrepo/pubkey.gpg", "").await).await;
let (public, _) =
SignedPublicKey::from_armor_single(std::io::Cursor::new(&key[..])).unwrap();
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/InRelease", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let inrelease = body_bytes(resp).await;
let (msg, _) =
CleartextSignedMessage::from_armor(std::io::Cursor::new(&inrelease[..])).unwrap();
msg.verify(&public).unwrap();
assert!(
msg.signed_text().contains("SHA256:"),
"InRelease must embed the Release body"
);
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/Release.gpg", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let gpg = body_bytes(resp).await;
let (sig, _) =
DetachedSignature::from_armor_single(std::io::Cursor::new(&gpg[..])).unwrap();
sig.verify(&public, &release[..]).unwrap();
let resp = send(&ctx.app, Method::GET, "/deb/myrepo/./InRelease", "").await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_deb_unsigned_mode_removes_stale_signatures() {
let ctx = create_test_context_with_config(|c| c.signing.enabled = false);
for name in ["InRelease", "Release.gpg"] {
ctx.state
.storage
.put(&format!("deb/myrepo/{name}"), b"stale")
.await
.unwrap();
}
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/sig.deb",
build_test_deb_with("sig", "1.0", "gz"),
)
.await;
for name in ["InRelease", "Release.gpg", "pubkey.gpg"] {
let resp = send(&ctx.app, Method::GET, &format!("/deb/myrepo/{name}"), "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{name}");
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod reindex_tests {
use super::integration_tests::build_test_deb_with;
use crate::test_helpers::{body_bytes, create_test_context, send};
use axum::http::{Method, StatusCode};
async fn packages_index(ctx: &crate::test_helpers::TestContext) -> String {
String::from_utf8(
body_bytes(send(&ctx.app, Method::GET, "/deb/myrepo/Packages", "").await)
.await
.to_vec(),
)
.unwrap()
}
#[tokio::test]
async fn test_deb_reindex_heals_both_directions() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/aaa.deb",
build_test_deb_with("aaa", "1.0", "gz"),
)
.await;
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/bbb.deb",
build_test_deb_with("bbb", "1.0", "gz"),
)
.await;
ctx.state
.storage
.delete("deb/myrepo/pool/aaa.deb")
.await
.unwrap();
ctx.state
.storage
.put(
"deb/myrepo/pool/ccc.deb",
&build_test_deb_with("ccc", "2.0", "zst"),
)
.await
.unwrap();
assert!(
packages_index(&ctx).await.contains("Package: aaa"),
"stale before reindex"
);
let resp = send(&ctx.app, Method::POST, "/deb/myrepo/-/reindex", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(&body_bytes(resp).await).unwrap();
assert_eq!(body["packages"], 2);
assert_eq!(body["orphans_removed"], 1);
assert_eq!(body["sidecars_created"], 1);
let p = packages_index(&ctx).await;
assert!(!p.contains("Package: aaa"), "deleted package must vanish");
assert!(p.contains("Package: bbb"));
assert!(p.contains("Package: ccc"), "adopted package must be served");
let inrelease = send(&ctx.app, Method::GET, "/deb/myrepo/InRelease", "").await;
assert_eq!(inrelease.status(), StatusCode::OK, "signature regenerated");
}
#[tokio::test]
async fn test_deb_reindex_rejects_invalid_package() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/aaa.deb",
build_test_deb_with("aaa", "1.0", "gz"),
)
.await;
ctx.state
.storage
.put("deb/myrepo/pool/junk.deb", b"not a deb")
.await
.unwrap();
let resp = send(&ctx.app, Method::POST, "/deb/myrepo/-/reindex", "").await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn test_deb_reindex_unknown_repo_404s() {
let ctx = create_test_context();
let resp = send(&ctx.app, Method::POST, "/deb/nosuch/-/reindex", "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod structured_tests {
use super::integration_tests::{build_test_deb_arch, build_test_deb_with};
use crate::test_helpers::{body_bytes, create_test_context, send};
use axum::http::{Method, StatusCode};
use pgp::composed::{CleartextSignedMessage, Deserializable, SignedPublicKey};
use sha2::Digest;
async fn get_text(ctx: &crate::test_helpers::TestContext, path: &str) -> String {
let resp = send(&ctx.app, Method::GET, path, "").await;
assert_eq!(resp.status(), StatusCode::OK, "{path}");
String::from_utf8(body_bytes(resp).await.to_vec()).unwrap()
}
#[tokio::test]
async fn test_deb_structured_upload_generates_dists_tree() {
let ctx = create_test_context();
let body = build_test_deb_with("tree", "2.1.0-1", "gz");
let resp = send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/main/t/tree_2.1.0-1_amd64.deb?distribution=jammy",
body.clone(),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let packages = get_text(&ctx, "/deb/myrepo/dists/jammy/main/binary-amd64/Packages").await;
assert!(packages.contains("Package: tree\n"), "{packages}");
assert!(packages.contains("Filename: pool/main/t/tree_2.1.0-1_amd64.deb\n"));
let sha256 = hex::encode(sha2::Sha256::digest(&body));
assert!(packages.contains(&format!("SHA256: {sha256}\n")));
let resp = send(
&ctx.app,
Method::GET,
"/deb/myrepo/dists/jammy/main/binary-amd64/Packages.gz",
"",
)
.await;
assert_eq!(
resp.headers()
.get("cache-control")
.unwrap()
.to_str()
.unwrap(),
"no-cache"
);
let gz = body_bytes(resp).await;
let mut plain = String::new();
std::io::Read::read_to_string(&mut flate2::read::GzDecoder::new(&gz[..]), &mut plain)
.unwrap();
assert_eq!(plain, packages);
let release = get_text(&ctx, "/deb/myrepo/dists/jammy/Release").await;
assert!(release.contains("Suite: jammy\n"), "{release}");
assert!(release.contains("Codename: jammy\n"));
assert!(release.contains("Architectures: amd64\n"));
assert!(release.contains("Components: main\n"));
let pkg_sha = hex::encode(sha2::Sha256::digest(packages.as_bytes()));
let gz_sha = hex::encode(sha2::Sha256::digest(&gz));
assert!(release.contains(&format!(
" {pkg_sha} {} main/binary-amd64/Packages\n",
packages.len()
)));
assert!(release.contains(&format!(
" {gz_sha} {} main/binary-amd64/Packages.gz\n",
gz.len()
)));
for name in ["Packages", "Release", "InRelease"] {
let resp = send(&ctx.app, Method::GET, &format!("/deb/myrepo/{name}"), "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{name}");
}
let key = body_bytes(send(&ctx.app, Method::GET, "/deb/myrepo/pubkey.gpg", "").await).await;
let (public, _) =
SignedPublicKey::from_armor_single(std::io::Cursor::new(&key[..])).unwrap();
let inrelease = get_text(&ctx, "/deb/myrepo/dists/jammy/InRelease").await;
let (msg, _) =
CleartextSignedMessage::from_armor(std::io::Cursor::new(inrelease.as_bytes())).unwrap();
msg.verify(&public).unwrap();
assert!(msg.signed_text().contains("Suite: jammy"));
}
#[tokio::test]
async fn test_deb_structured_arch_all_folding() {
let ctx = create_test_context();
for (path, body) in [
(
"a_1_amd64.deb",
build_test_deb_arch("a", "1", "amd64", "gz"),
),
(
"b_1_arm64.deb",
build_test_deb_arch("b", "1", "arm64", "gz"),
),
("c_1_all.deb", build_test_deb_arch("c", "1", "all", "gz")),
] {
let resp = send(
&ctx.app,
Method::PUT,
&format!("/deb/myrepo/pool/{path}?distribution=jammy"),
body,
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED, "{path}");
}
let amd = get_text(&ctx, "/deb/myrepo/dists/jammy/main/binary-amd64/Packages").await;
assert!(
amd.contains("Package: a\n") && amd.contains("Package: c\n"),
"{amd}"
);
assert!(!amd.contains("Package: b\n"));
let arm = get_text(&ctx, "/deb/myrepo/dists/jammy/main/binary-arm64/Packages").await;
assert!(
arm.contains("Package: b\n") && arm.contains("Package: c\n"),
"{arm}"
);
let release = get_text(&ctx, "/deb/myrepo/dists/jammy/Release").await;
assert!(
release.contains("Architectures: amd64 arm64\n"),
"{release}"
);
let resp = send(
&ctx.app,
Method::GET,
"/deb/myrepo/dists/jammy/main/binary-all/Packages",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_deb_structured_all_only_dist_publishes_binary_all() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/c_1_all.deb?distribution=jammy",
build_test_deb_arch("c", "1", "all", "gz"),
)
.await;
let all = get_text(&ctx, "/deb/myrepo/dists/jammy/main/binary-all/Packages").await;
assert!(all.contains("Package: c\n"));
let release = get_text(&ctx, "/deb/myrepo/dists/jammy/Release").await;
assert!(release.contains("Architectures: all\n"), "{release}");
}
#[tokio::test]
async fn test_deb_structured_component_arch_matrix() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/a_1_amd64.deb?distribution=jammy&component=main",
build_test_deb_arch("a", "1", "amd64", "gz"),
)
.await;
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/b_1_arm64.deb?distribution=jammy&component=contrib",
build_test_deb_arch("b", "1", "arm64", "gz"),
)
.await;
let release = get_text(&ctx, "/deb/myrepo/dists/jammy/Release").await;
assert!(release.contains("Components: contrib main\n"), "{release}");
for combo in [
"main/binary-amd64",
"main/binary-arm64",
"contrib/binary-amd64",
"contrib/binary-arm64",
] {
assert!(release.contains(&format!(" {combo}/Packages\n")), "{combo}");
let idx = get_text(&ctx, &format!("/deb/myrepo/dists/jammy/{combo}/Packages")).await;
match combo {
"main/binary-amd64" => assert!(idx.contains("Package: a\n")),
"contrib/binary-arm64" => assert!(idx.contains("Package: b\n")),
_ => assert!(!idx.contains("Package:"), "{combo} must be empty: {idx}"),
}
}
}
#[tokio::test]
async fn test_deb_mixed_flat_and_structured() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/flat.deb",
build_test_deb_with("flatpkg", "1.0", "gz"),
)
.await;
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/s_1_amd64.deb?distribution=jammy",
build_test_deb_arch("structpkg", "1", "amd64", "gz"),
)
.await;
let flat = get_text(&ctx, "/deb/myrepo/Packages").await;
assert!(flat.contains("Package: flatpkg\n") && !flat.contains("Package: structpkg"));
let dist = get_text(&ctx, "/deb/myrepo/dists/jammy/main/binary-amd64/Packages").await;
assert!(dist.contains("Package: structpkg\n") && !dist.contains("Package: flatpkg"));
}
#[tokio::test]
async fn test_deb_structured_delete_cleans_dists_tree() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/a_1_amd64.deb?distribution=jammy",
build_test_deb_arch("a", "1", "amd64", "gz"),
)
.await;
let resp = send(
&ctx.app,
Method::DELETE,
"/deb/myrepo/pool/a_1_amd64.deb",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
for path in [
"dists/jammy/Release",
"dists/jammy/InRelease",
"dists/jammy/main/binary-amd64/Packages",
] {
let resp = send(&ctx.app, Method::GET, &format!("/deb/myrepo/{path}"), "").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{path}");
}
let flat = get_text(&ctx, "/deb/myrepo/Packages").await;
assert!(!flat.contains("Package:"), "{flat}");
}
#[tokio::test]
async fn test_deb_structured_upload_rejections() {
let ctx = create_test_context();
let deb = build_test_deb_with("x", "1.0", "gz");
for (path, expect) in [
("/deb/myrepo/x.deb?component=main", "component alone"),
("/deb/myrepo/x.deb?distribution=ja%20mmy", "space in dist"),
(
"/deb/myrepo/x.deb?distribution=jammy&component=..",
"dot component",
),
("/deb/myrepo/dists/jammy/x.deb", "upload into dists/"),
] {
let resp = send(&ctx.app, Method::PUT, path, deb.clone()).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{expect}");
}
assert!(ctx.state.storage.list("deb/").await.unwrap().is_empty());
}
#[tokio::test]
async fn test_deb_structured_reindex() {
let ctx = create_test_context();
send(
&ctx.app,
Method::PUT,
"/deb/myrepo/pool/a_1_amd64.deb?distribution=jammy",
build_test_deb_arch("a", "1", "amd64", "gz"),
)
.await;
ctx.state
.storage
.put(
"deb/myrepo/pool/adopted.deb",
&build_test_deb_with("adopted", "2.0", "gz"),
)
.await
.unwrap();
let resp = send(&ctx.app, Method::POST, "/deb/myrepo/-/reindex", "").await;
assert_eq!(resp.status(), StatusCode::OK);
let dist = get_text(&ctx, "/deb/myrepo/dists/jammy/main/binary-amd64/Packages").await;
assert!(dist.contains("Package: a\n"), "placement survives reindex");
let flat = get_text(&ctx, "/deb/myrepo/Packages").await;
assert!(flat.contains("Package: adopted\n"), "adopted into flat");
assert!(!flat.contains("Package: a\n"));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod proxy_tests {
use crate::config::registry::RepoProxyEntry;
use crate::test_helpers::{body_bytes, create_test_context_with_config, send};
use axum::http::{Method, StatusCode};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_deb_proxy_fetches_structured_layout() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dists/bookworm/InRelease"))
.respond_with(ResponseTemplate::new(200).set_body_string("Origin: Debian"))
.mount(&upstream)
.await;
Mock::given(method("GET"))
.and(path("/pool/main/a/a_1.0_amd64.deb"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"debbytes".to_vec()))
.mount(&upstream)
.await;
let uri = upstream.uri();
let ctx = create_test_context_with_config(move |cfg| {
cfg.deb.enabled = true;
cfg.deb
.proxies
.insert("debian".to_string(), RepoProxyEntry::Simple(uri));
});
let resp = send(
&ctx.app,
Method::GET,
"/deb/debian/dists/bookworm/InRelease",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(&body_bytes(resp).await[..], b"Origin: Debian");
let resp = send(
&ctx.app,
Method::GET,
"/deb/debian/pool/main/a/a_1.0_amd64.deb",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(&body_bytes(resp).await[..], b"debbytes");
}
#[tokio::test]
async fn test_deb_proxy_strips_flat_prefix() {
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/InRelease"))
.respond_with(ResponseTemplate::new(200).set_body_string("flat"))
.mount(&upstream)
.await;
let uri = upstream.uri();
let ctx = create_test_context_with_config(move |cfg| {
cfg.deb.enabled = true;
cfg.deb
.proxies
.insert("debian".to_string(), RepoProxyEntry::Simple(uri));
});
let resp = send(&ctx.app, Method::GET, "/deb/debian/./InRelease", "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(&body_bytes(resp).await[..], b"flat");
}
#[tokio::test]
async fn test_deb_proxied_repo_rejects_writes() {
let ctx = create_test_context_with_config(|cfg| {
cfg.deb.enabled = true;
cfg.deb.proxies.insert(
"debian".to_string(),
RepoProxyEntry::Simple("http://upstream.invalid".to_string()),
);
});
let resp = send(&ctx.app, Method::PUT, "/deb/debian/a_1.0_amd64.deb", "x").await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let resp = send(&ctx.app, Method::DELETE, "/deb/debian/a_1.0_amd64.deb", "").await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let resp = send(&ctx.app, Method::POST, "/deb/debian/-/reindex", "").await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
}