use std::collections::BTreeMap;
use std::path::PathBuf;
use sha2::{Digest, Sha256};
use crate::activation::ActivationProvider;
use crate::artifact::{ArtifactProvenance, ArtifactStore, VerifiedArtifact, digest_tree};
use crate::backends::typed::{BackendContext, BackendOperation, install_operations};
use crate::config::schema::{GitInstall, InstallSpec};
use crate::error::ForgeError;
use crate::execution::command::{ShellDisplayMode, ShellStep, run_invocation_labeled};
use crate::execution::install::{installation_verification_result, verify_typed_tool};
use crate::execution::managed_cargo::{rollback_activations, switch_activations};
use crate::execution::policy::enforce_network_policy;
use crate::fsutil::{
NamedFileLease, acquire_named_lock, copy_file, create_dir_all, remove_dir_all,
};
use crate::model::{
BackendKind, InstallKind, RegistryEntry, RegistryTarget, ToolDef, ToolInstallOutcome,
ToolInstallResult,
};
use crate::paths::{app_home, managed_bin_dir};
use crate::planning::ExecutionPlan;
use crate::state::journal::{JournalCheckpoint, JournalPhase};
use crate::state::{append_registry, read_registry};
use crate::util::{exe_name, now_secs};
pub(crate) fn install(
plan: &ExecutionPlan,
tool: &ToolDef,
specification: &GitInstall,
display_mode: ShellDisplayMode,
step: Option<ShellStep>,
) -> Result<ToolInstallResult, ForgeError> {
let fingerprint = git_fingerprint(specification)?;
let artifact_id = format!("{}-{fingerprint}", tool.name);
let provenance = ArtifactProvenance {
backend: "git".into(),
source: specification.url.clone(),
revision: Some(specification.revision.clone()),
fingerprint,
};
let expected_binaries = specification.bins.keys().cloned().collect::<Vec<_>>();
let store = ArtifactStore::new(None)?;
let mut cached = store
.get(&artifact_id, &provenance, &expected_binaries)?
.artifact;
if cached.is_none() {
enforce_network_policy(
plan.policy.network,
&InstallSpec::Git(specification.clone()),
)?;
}
let work_identity = artifact_id.clone();
let _work_lease = acquire_named_lock("git-work", &work_identity)?;
if cached.is_none() {
cached = store
.get(&artifact_id, &provenance, &expected_binaries)?
.artifact;
}
let (artifact, stored) = if let Some(artifact) = cached {
let stored = artifact.root.clone();
(artifact, stored)
} else {
let artifact = build_artifact(
tool,
specification,
&artifact_id,
provenance,
display_mode,
step,
)?;
let _artifact_cleanup = DirectoryCleanup::new(artifact.root.clone());
let _artifact_lease = acquire_named_lock("git-artifacts", &artifact.id)?;
let stored = store.put(&artifact)?;
(artifact, stored)
};
let activator = ActivationProvider::new(managed_bin_dir())?;
let previous_artifact_id = read_registry()?
.into_iter()
.find(|entry| entry.name == tool.name && entry.kind == InstallKind::Tool)
.and_then(|entry| entry.artifact_id);
let mut targets = Vec::new();
let mut registry_targets = Vec::new();
let mut prepared = Vec::new();
let mut _bin_leases: Vec<NamedFileLease> = Vec::new();
for (name, path) in &artifact.binaries {
_bin_leases.push(acquire_named_lock("activation", name)?);
let relative = path.strip_prefix(&artifact.root).map_err(|_| {
ForgeError::Config(format!(
"Git artifact binary escapes the root: {}",
path.display()
))
})?;
let stored_binary = stored.join(relative);
let activation = activator.prepare(name, &stored_binary)?;
targets.push(activation.destination().to_path_buf());
registry_targets.push(RegistryTarget {
path: activation.destination().to_path_buf(),
binary: Some(name.clone()),
});
prepared.push(activation);
}
let mut journal = JournalCheckpoint::new(
format!(
"{}-{}-{}",
&plan.plan_hash[..plan.plan_hash.len().min(12)],
std::process::id(),
now_secs()
),
&tool.name,
&plan.plan_hash,
&plan.config_hash,
&plan.profile,
);
journal.save()?;
journal.update_artifacts(&artifact.id, previous_artifact_id.clone(), targets.clone())?;
journal.transition(JournalPhase::Stored)?;
journal.transition(JournalPhase::Activating)?;
let switched = switch_activations(&prepared, &mut journal)?;
journal.transition(JournalPhase::Activated)?;
let result = installation_verification_result(tool, step)?;
if result.outcome != ToolInstallOutcome::Installed {
rollback_activations(&prepared, switched)?;
journal.transition(JournalPhase::RolledBack)?;
return Ok(result);
}
if let Err(error) = verify_typed_tool(tool) {
rollback_activations(&prepared, switched)?;
journal.transition(JournalPhase::RolledBack)?;
return Err(ForgeError::Command(format!(
"verification failed for tool {}: {error}",
tool.name
)));
}
let entry = RegistryEntry {
name: tool.name.clone(),
kind: InstallKind::Tool,
source: "backend:git".to_string(),
profile: plan.profile.clone(),
targets: registry_targets,
installed_at: now_secs(),
artifact_id: Some(artifact.id),
previous_artifact_id,
config_hash: Some(plan.config_hash.clone()),
plan_hash: Some(plan.plan_hash.clone()),
source_revision: Some(specification.revision.clone()),
backend: Some(BackendKind::Git),
};
if let Err(error) = append_registry(entry) {
rollback_activations(&prepared, switched)?;
journal.transition(JournalPhase::RolledBack)?;
return Err(error);
}
journal.transition(JournalPhase::Recorded)?;
Ok(result)
}
fn git_fingerprint(specification: &GitInstall) -> Result<String, ForgeError> {
let identity = serde_json::to_vec(&serde_json::json!({
"url": specification.url,
"revision": specification.revision,
"subdirectory": specification.subdirectory,
"bins": specification.bins,
}))
.map_err(|error| {
ForgeError::Parse(format!(
"failed to serialize Git artifact identity: {error}"
))
})?;
Ok(Sha256::digest(identity)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
fn build_artifact(
tool: &ToolDef,
specification: &GitInstall,
artifact_id: &str,
provenance: ArtifactProvenance,
display_mode: ShellDisplayMode,
step: Option<ShellStep>,
) -> Result<VerifiedArtifact, ForgeError> {
let work = app_home().join("work").join("git").join(artifact_id);
if work.exists() {
remove_dir_all(&work)?;
}
let target = work.join("target");
create_dir_all(&work)?;
let _work_cleanup = DirectoryCleanup::new(work.clone());
let context = BackendContext {
staging: &work,
target: &target,
cargo_jobs: 1,
allow_insecure_hosts: &[],
apt_source: None,
apt_lists: None,
};
for operation in install_operations(&InstallSpec::Git(specification.clone()), &context)? {
let BackendOperation::Command(invocation) = operation else {
return Err(ForgeError::Config(
"Git backend produced a non-command operation".into(),
));
};
run_invocation_labeled(&tool.name, &invocation, display_mode, step)?;
}
let repository_root = specification
.subdirectory
.as_ref()
.map_or_else(|| work.clone(), |path| work.join(path));
if !repository_root.is_dir() {
return Err(ForgeError::Config(format!(
"Git subdirectory does not exist: {}",
repository_root.display()
)));
}
let artifact_root = app_home()
.join("work")
.join("git-artifacts")
.join(artifact_id);
if artifact_root.exists() {
remove_dir_all(&artifact_root)?;
}
create_dir_all(&artifact_root)?;
let mut artifact_cleanup = DirectoryCleanup::new(artifact_root.clone());
let mut binaries = BTreeMap::new();
for (name, relative) in &specification.bins {
let source = repository_root.join(relative);
let destination = artifact_root.join(exe_name(name));
copy_file(&source, &destination)?;
binaries.insert(name.clone(), destination);
}
let sha256 = digest_tree(&artifact_root)?;
artifact_cleanup.keep();
Ok(VerifiedArtifact {
id: artifact_id.to_string(),
root: artifact_root,
binaries,
sha256,
provenance,
})
}
struct DirectoryCleanup {
path: PathBuf,
keep: bool,
}
impl DirectoryCleanup {
fn new(path: PathBuf) -> Self {
Self { path, keep: false }
}
fn keep(&mut self) {
self.keep = true;
}
}
impl Drop for DirectoryCleanup {
fn drop(&mut self) {
if !self.keep && self.path.exists() {
let _ = remove_dir_all(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use crate::execution::managed_git::DirectoryCleanup;
use crate::util::now_secs;
#[test]
fn failed_git_build_cleanup_removes_partial_directories() {
let root = std::env::temp_dir().join(format!(
"bot-forge-git-cleanup-{}-{}",
std::process::id(),
now_secs()
));
fs::create_dir_all(root.join("partial")).unwrap();
{
let _cleanup = DirectoryCleanup::new(root.clone());
}
assert!(!root.exists());
}
}