use std::path::{Path, PathBuf};
use crate::config::schema::CargoInstall;
use crate::error::ForgeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SourceMode {
Online,
CacheOnly,
Offline,
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedCargo {
pub(crate) component: String,
pub(crate) fingerprint: String,
pub(crate) staging: PathBuf,
pub(crate) target_dir: PathBuf,
pub(crate) source_mode: SourceMode,
}
impl ResolvedCargo {
pub(crate) fn resolve(
component: &str,
specification: &CargoInstall,
work_root: &Path,
source_mode: SourceMode,
) -> Result<Self, ForgeError> {
let version = specification.version.trim_start_matches('=');
if version.is_empty()
|| version
.bytes()
.any(|byte| matches!(byte, b'*' | b'^' | b'~' | b'>' | b'<'))
{
return Err(ForgeError::Config(format!(
"Cargo component {component} must resolve to a pinned version"
)));
}
if specification.source.as_deref().is_some_and(|source| {
source.starts_with("git+")
&& specification.revision.as_deref().is_none_or(str::is_empty)
}) {
return Err(ForgeError::Config(format!(
"Cargo Git component {component} must pin a revision"
)));
}
let source_digest = specification.source_digest();
let lock_digest = specification.lock_digest();
let fingerprint = specification.artifact_fingerprint(&source_digest, &lock_digest);
Ok(Self {
component: component.to_string(),
staging: work_root.join("staging").join(&fingerprint),
target_dir: work_root.join("targets").join(&fingerprint),
fingerprint,
source_mode,
})
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::path::Path;
use crate::backends::cargo::{CargoInstall, ResolvedCargo, SourceMode};
fn specification() -> CargoInstall {
CargoInstall {
crate_name: "demo".into(),
version: "=1.2.3".into(),
source: None,
revision: None,
locked: true,
features: vec!["json".into()],
bins: vec!["demo".into()],
target: None,
toolchain: None,
profile: "release".into(),
build_env_allow: Vec::new(),
}
}
#[test]
fn fingerprints_get_isolated_target_shards() {
let resolved = ResolvedCargo::resolve(
"demo",
&specification(),
Path::new("/work"),
SourceMode::Online,
)
.unwrap();
assert!(resolved.target_dir.ends_with(&resolved.fingerprint));
}
#[test]
fn rejects_floating_versions_and_git_revisions() {
let mut spec = specification();
spec.version = "^1".into();
assert!(
ResolvedCargo::resolve("demo", &spec, Path::new("/work"), SourceMode::Online).is_err()
);
spec.version = "=1.2.3".into();
spec.source = Some("git+https://example.invalid/demo".into());
assert!(
ResolvedCargo::resolve("demo", &spec, Path::new("/work"), SourceMode::Online).is_err()
);
}
#[test]
fn explicitly_unlocked_cargo_has_a_distinct_fingerprint() {
let mut spec = specification();
let locked =
ResolvedCargo::resolve("demo", &spec, Path::new("/work"), SourceMode::Online).unwrap();
spec.locked = false;
let unlocked =
ResolvedCargo::resolve("demo", &spec, Path::new("/work"), SourceMode::Online).unwrap();
assert_ne!(locked.fingerprint, unlocked.fingerprint);
}
#[test]
fn stress_fingerprints_and_multi_bin_verification_are_isolated() {
let work =
std::env::temp_dir().join(format!("bot-forge-cargo-stress-{}", std::process::id()));
let mut fingerprints = BTreeSet::new();
for index in 0..20 {
let mut spec = specification();
spec.crate_name = format!("crate-{index}");
spec.bins = vec![format!("bin-{index}-a"), format!("bin-{index}-b")];
let resolved = ResolvedCargo::resolve(
&format!("component-{index}"),
&spec,
&work,
SourceMode::Offline,
)
.unwrap();
assert!(fingerprints.insert(resolved.fingerprint.clone()));
assert_eq!(resolved.source_mode, SourceMode::Offline);
}
assert_eq!(fingerprints.len(), 20);
let first = ResolvedCargo::resolve(
"component-duplicate",
&specification(),
&work,
SourceMode::CacheOnly,
)
.unwrap();
let second = ResolvedCargo::resolve(
"component-duplicate",
&specification(),
&work,
SourceMode::CacheOnly,
)
.unwrap();
assert_eq!(first.fingerprint, second.fingerprint);
assert_eq!(first.target_dir, second.target_dir);
}
}