use std::path::{Path, PathBuf};
use axum::body::Bytes;
use futures::stream::BoxStream;
use futures::StreamExt;
use sha2::Digest;
use super::{ArtifactRef, Result, SourceRegistry};
use crate::curation::{CurationEngine, Decision, FilterRequest};
use crate::registry_type::RegistryType;
use crate::storage::Storage;
struct TempFileGuard {
path: Option<PathBuf>,
}
impl TempFileGuard {
fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
fn disarm(&mut self) {
self.path = None;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if let Some(ref p) = self.path {
let _ = std::fs::remove_file(p);
}
}
}
#[derive(Debug)]
pub enum Outcome {
Imported { bytes: u64, sha256: String },
Skipped { reason: String },
Failed { reason: String },
}
#[derive(Debug, Clone, Copy)]
pub struct TransferOpts {
pub dry_run: bool,
pub allow_sha1: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Provenance {
Strong,
Weak,
}
enum VerifyPlan {
Verify(Provenance),
Skip(&'static str),
Fail(&'static str),
}
fn plan_verification(a: &ArtifactRef, allow_sha1: bool) -> VerifyPlan {
if a.sha256.is_some() {
VerifyPlan::Verify(Provenance::Strong)
} else if a.sha1.is_some() {
if allow_sha1 {
VerifyPlan::Verify(Provenance::Weak)
} else {
VerifyPlan::Skip(
"sha1-only artifact; pass --allow-sha1 to import (SHA-1 is collision-broken)",
)
}
} else {
VerifyPlan::Fail(
"no source checksum advertised; refusing to import an unverifiable artifact",
)
}
}
#[allow(clippy::too_many_arguments)]
pub async fn transfer_artifact(
source: &dyn SourceRegistry,
artifact: &ArtifactRef,
key: &str,
rt: RegistryType,
curation_name: &str,
source_host: &str,
storage: &Storage,
curation: &CurationEngine,
temp_dir: &Path,
opts: TransferOpts,
) -> Outcome {
let provenance = match plan_verification(artifact, opts.allow_sha1) {
VerifyPlan::Verify(p) => p,
VerifyPlan::Skip(r) => {
return Outcome::Skipped {
reason: r.to_string(),
}
}
VerifyPlan::Fail(r) => {
return Outcome::Failed {
reason: format!("{} ({})", r, artifact.path),
}
}
};
let stream = match source.download_stream(artifact).await {
Ok(s) => s,
Err(e) => {
return Outcome::Failed {
reason: format!("download {}: {e}", artifact.path),
}
}
};
let staged = match stage_to_temp(stream, temp_dir).await {
Ok(s) => s,
Err(e) => {
return Outcome::Failed {
reason: format!("stage {}: {e}", artifact.path),
}
}
};
let Staged {
path: temp_path,
mut guard,
sha256,
sha1,
bytes,
} = staged;
if let Some(expected) = artifact.size {
if expected != bytes {
return Outcome::Failed {
reason: format!(
"size mismatch for {} ({}): source {expected} != downloaded {bytes}",
artifact.path, artifact.name
),
};
}
}
if let Err(reason) = verify_checksum(artifact, &sha256, &sha1, provenance) {
return Outcome::Failed { reason }; }
let req = FilterRequest {
registry: rt,
upstream: Some(source_host.to_string()),
name: curation_name.to_string(),
version: None,
integrity: Some(format!("sha256:{sha256}")),
bypass: false,
publish_date: None,
};
let result = curation.evaluate(&req);
if !should_commit(&result.decision, result.audited) {
return Outcome::Skipped {
reason: format!(
"curation blocked by {}",
result.decided_by.as_deref().unwrap_or("policy")
),
};
}
if opts.dry_run {
return Outcome::Imported { bytes, sha256 }; }
let _ = provenance; match storage.put_from_path(key, &temp_path, Some(&sha256)).await {
Ok(()) => {
guard.disarm(); Outcome::Imported { bytes, sha256 }
}
Err(e) => Outcome::Failed {
reason: format!("commit {key}: {e}"),
},
}
}
struct Staged {
path: PathBuf,
guard: TempFileGuard,
sha256: String,
sha1: String,
bytes: u64,
}
async fn stage_to_temp(
mut stream: BoxStream<'static, Result<Bytes>>,
temp_dir: &Path,
) -> Result<Staged> {
tokio::fs::create_dir_all(temp_dir)
.await
.map_err(|e| format!("create temp dir: {e}"))?;
let temp_path = temp_dir.join(format!("import-{}", uuid::Uuid::new_v4()));
let guard = TempFileGuard::new(temp_path.clone());
let mut file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| format!("create temp file: {e}"))?;
let mut h256 = sha2::Sha256::new();
let mut h1 = sha1::Sha1::new(); let mut bytes: u64 = 0;
use tokio::io::AsyncWriteExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
h256.update(&chunk);
h1.update(&chunk);
file.write_all(&chunk)
.await
.map_err(|e| format!("write temp: {e}"))?;
bytes += chunk.len() as u64;
}
file.flush().await.map_err(|e| format!("flush temp: {e}"))?;
file.sync_all()
.await
.map_err(|e| format!("fsync temp: {e}"))?;
drop(file);
let sha256 = hex::encode(h256.finalize());
let sha1 = hex::encode(h1.finalize());
Ok(Staged {
path: temp_path,
guard,
sha256,
sha1,
bytes,
})
}
fn verify_checksum(
a: &ArtifactRef,
sha256_local: &str,
sha1_local: &str,
provenance: Provenance,
) -> std::result::Result<(), String> {
let (label, want, got) = match provenance {
Provenance::Strong => ("sha256", a.sha256.as_deref().unwrap_or(""), sha256_local),
Provenance::Weak => ("sha1", a.sha1.as_deref().unwrap_or(""), sha1_local),
};
if want.eq_ignore_ascii_case(got) {
Ok(())
} else {
Err(format!(
"{label} mismatch for {}: source {} != local {}",
a.path,
short(want),
short(got)
))
}
}
fn should_commit(decision: &Decision, audited: bool) -> bool {
match decision {
Decision::Allow | Decision::Skip => true,
Decision::Block { .. } => audited,
}
}
fn short(hex: &str) -> String {
hex.chars().take(12).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn art(sha256: Option<&str>, sha1: Option<&str>) -> ArtifactRef {
ArtifactRef {
repo: "r".into(),
path: "a/b.jar".into(),
name: "b.jar".into(),
size: None,
sha256: sha256.map(String::from),
sha1: sha1.map(String::from),
}
}
#[test]
fn plan_verification_applies_r8_policy() {
assert!(matches!(
plan_verification(&art(Some("aa"), None), false),
VerifyPlan::Verify(Provenance::Strong)
));
assert!(matches!(
plan_verification(&art(None, Some("bb")), true),
VerifyPlan::Verify(Provenance::Weak)
));
assert!(matches!(
plan_verification(&art(None, Some("bb")), false),
VerifyPlan::Skip(_)
));
assert!(matches!(
plan_verification(&art(None, None), true),
VerifyPlan::Fail(_)
));
}
#[test]
fn verify_checksum_is_case_insensitive_and_fail_closed() {
let a = art(Some("ABCDEF"), None);
assert!(verify_checksum(&a, "abcdef", "", Provenance::Strong).is_ok());
assert!(verify_checksum(&a, "abcde0", "", Provenance::Strong).is_err());
let a1 = art(None, Some("aa11"));
assert!(verify_checksum(&a1, "ignored", "AA11", Provenance::Weak).is_ok());
assert!(verify_checksum(&a1, "ignored", "bb22", Provenance::Weak).is_err());
}
#[test]
fn should_commit_honors_audit_and_enforce() {
assert!(should_commit(&Decision::Allow, false));
assert!(should_commit(&Decision::Skip, false));
assert!(!should_commit(
&Decision::Block {
rule: "blocklist".into(),
reason: "x".into()
},
false
));
assert!(should_commit(
&Decision::Block {
rule: "blocklist".into(),
reason: "x".into()
},
true
));
}
use async_trait::async_trait;
use futures::stream;
struct MockSource {
body: Vec<u8>,
fail_download: bool,
}
#[async_trait]
impl SourceRegistry for MockSource {
async fn list_repositories(&self) -> Result<Vec<crate::import::RepoRef>> {
Ok(vec![])
}
fn artifacts<'a>(&'a self, _repo: &'a str) -> BoxStream<'a, Result<ArtifactRef>> {
stream::empty().boxed()
}
async fn download_stream(
&self,
_artifact: &ArtifactRef,
) -> Result<BoxStream<'static, Result<Bytes>>> {
if self.fail_download {
return Err("mock download failure".to_string());
}
let body = self.body.clone();
Ok(stream::once(async move { Ok(Bytes::from(body)) }).boxed())
}
}
async fn harness() -> (Storage, CurationEngine, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let storage = Storage::new_local(tmp.path().to_str().unwrap());
let curation = CurationEngine::new(crate::config::CurationConfig::default());
(storage, curation, tmp)
}
fn full_art(size: Option<u64>, sha256: Option<&str>, sha1: Option<&str>) -> ArtifactRef {
ArtifactRef {
repo: "r".into(),
path: "com/x/1.0/x-1.0.jar".into(),
name: "x-1.0.jar".into(),
size,
sha256: sha256.map(String::from),
sha1: sha1.map(String::from),
}
}
const KEY: &str = "maven/com/x/1.0/x-1.0.jar";
async fn run_transfer(
src: &MockSource,
art: &ArtifactRef,
storage: &Storage,
curation: &CurationEngine,
td: &std::path::Path,
opts: TransferOpts,
) -> Outcome {
transfer_artifact(
src,
art,
KEY,
RegistryType::Maven,
"com/x",
"src-host",
storage,
curation,
td,
opts,
)
.await
}
fn commit_opts() -> TransferOpts {
TransferOpts {
dry_run: false,
allow_sha1: false,
}
}
#[tokio::test]
async fn transfer_no_checksum_is_failed_no_commit() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let src = MockSource {
body: b"x".to_vec(),
fail_download: false,
};
let out = run_transfer(
&src,
&full_art(None, None, None),
&storage,
&curation,
&td,
commit_opts(),
)
.await;
assert!(matches!(out, Outcome::Failed { .. }));
assert!(storage.stat(KEY).await.is_none());
}
#[tokio::test]
async fn transfer_sha1_only_without_flag_is_skipped() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let src = MockSource {
body: b"x".to_vec(),
fail_download: false,
};
let out = run_transfer(
&src,
&full_art(None, None, Some("aa")),
&storage,
&curation,
&td,
commit_opts(),
)
.await;
assert!(matches!(out, Outcome::Skipped { .. }));
}
#[tokio::test]
async fn transfer_download_error_is_failed() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let sha = hex::encode(sha2::Sha256::digest(b"x"));
let src = MockSource {
body: vec![],
fail_download: true,
};
let out = run_transfer(
&src,
&full_art(None, Some(&sha), None),
&storage,
&curation,
&td,
commit_opts(),
)
.await;
assert!(matches!(out, Outcome::Failed { .. }));
}
#[tokio::test]
async fn transfer_size_mismatch_is_failed_no_commit() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let body = b"hello";
let sha = hex::encode(sha2::Sha256::digest(body));
let src = MockSource {
body: body.to_vec(),
fail_download: false,
};
let out = run_transfer(
&src,
&full_art(Some(999), Some(&sha), None),
&storage,
&curation,
&td,
commit_opts(),
)
.await;
assert!(matches!(out, Outcome::Failed { .. }));
assert!(storage.stat(KEY).await.is_none());
}
#[tokio::test]
async fn transfer_checksum_mismatch_fails_closed() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let wrong = hex::encode(sha2::Sha256::digest(b"other"));
let src = MockSource {
body: b"real".to_vec(),
fail_download: false,
};
let out = run_transfer(
&src,
&full_art(None, Some(&wrong), None),
&storage,
&curation,
&td,
commit_opts(),
)
.await;
assert!(matches!(out, Outcome::Failed { .. }));
assert!(storage.stat(KEY).await.is_none());
}
#[tokio::test]
async fn transfer_happy_path_commits_and_pins() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let body = b"hello world payload";
let sha = hex::encode(sha2::Sha256::digest(body));
let src = MockSource {
body: body.to_vec(),
fail_download: false,
};
let out = run_transfer(
&src,
&full_art(Some(body.len() as u64), Some(&sha), None),
&storage,
&curation,
&td,
commit_opts(),
)
.await;
match out {
Outcome::Imported { bytes, sha256 } => {
assert_eq!(bytes as usize, body.len());
assert_eq!(sha256, sha);
}
o => panic!("expected Imported, got {o:?}"),
}
assert_eq!(storage.get(KEY).await.unwrap().as_ref(), body);
assert_eq!(storage.get_pin_hash(KEY).as_deref(), Some(sha.as_str()));
}
#[tokio::test]
async fn transfer_dry_run_verifies_without_commit() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let body = b"rehearsal";
let sha = hex::encode(sha2::Sha256::digest(body));
let src = MockSource {
body: body.to_vec(),
fail_download: false,
};
let out = run_transfer(
&src,
&full_art(Some(body.len() as u64), Some(&sha), None),
&storage,
&curation,
&td,
TransferOpts {
dry_run: true,
allow_sha1: false,
},
)
.await;
assert!(matches!(out, Outcome::Imported { .. }));
assert!(storage.stat(KEY).await.is_none());
}
#[tokio::test]
async fn transfer_sha1_verified_with_flag_commits() {
let (storage, curation, tmp) = harness().await;
let td = tmp.path().join("t");
let body = b"legacy maven artifact";
let sha1v = hex::encode(sha1::Sha1::digest(body));
let src = MockSource {
body: body.to_vec(),
fail_download: false,
};
let out = run_transfer(
&src,
&full_art(None, None, Some(&sha1v)),
&storage,
&curation,
&td,
TransferOpts {
dry_run: false,
allow_sha1: true,
},
)
.await;
assert!(matches!(out, Outcome::Imported { .. }));
assert!(storage.stat(KEY).await.is_some());
}
}