use std::{
borrow::Cow,
collections::{BTreeMap, HashMap},
path::PathBuf,
time::SystemTime,
};
use anyhow::Context;
use serde::Deserialize;
use crate::{
channel::Channel,
commands,
config::Config,
fault,
identity::InstallationId,
options::{InstallationOptions, IntentUpdate},
paths,
resolve::Intent,
state::{Installation, LocalState, PublicationId, PublicationRef},
utils,
version::{Authority, GitTarget},
};
pub fn install(
config: &Config,
channel: &Channel,
state: &mut LocalState,
options: &InstallationOptions,
) -> anyhow::Result<()> {
anyhow::ensure!(
options.custom.is_none() || options.network.is_none(),
"a custom installation cannot also publish a global network link"
);
if let Some(network) = &options.network {
crate::channel::validate_network_name(network)?;
}
commands::setup_midenup(config)?;
let home = &config.midenup_home;
let installation_id = options.installation_id(&channel.name);
let publication_id = PublicationId::generate();
let publication = paths::publication_dir(home, &channel.name, &publication_id);
let intent = carry_migrated_intent(
state,
channel,
&installation_id,
effective_intent(state, channel, options),
);
let plan = crate::plan::build_plan(channel, &intent, config.target(), &publication)?;
let previous = previous_publication(config, state, &installation_id);
let mut stale = options.stale.clone();
if let Some(installed) = state.get_by_id(&installation_id) {
stale.extend(crate::toolchain::changed_patches(&installed.patches, &options.patches));
stale.extend(installed.components.iter().filter_map(|old| {
if options.held_back.iter().any(|held| held.name == old.name) {
return None;
}
let new = channel.get_component(&old.name)?;
let changed_source = plan
.steps
.iter()
.filter(|step| step.owner() == old.name.as_ref())
.filter_map(|step| step.authority())
.any(|authority| old.version.source_pin().changed_to(authority.source_pin()));
(changed_source
|| matches!(
commands::update::classify(
old,
new,
config.target(),
&config.working_directory
),
commands::update::ChangeClass::InstallationImpacting
))
.then(|| old.name.to_string())
}));
}
let mut entry = crate::publish::JournalEntry::install(
channel.name.clone(),
previous_publication_id(state, &installation_id),
publication_id.clone(),
target_installation(config, channel, &intent, options, &publication_id, &plan)?,
);
if let Some(previous) = state.get_by_id(&installation_id) {
entry = entry.with_previous(previous);
}
if options.custom.is_some() {
state.save(&paths::state_path(home))?;
}
crate::publish::journal::prepare(home, &entry)?;
fault::fail_at(fault::FaultPoint::PostPrepare)?;
crate::install::prepare(&publication)?;
if let Some((previous_dir, receipt)) = &previous {
crate::install::seed(
&plan,
&publication,
&crate::install::Seed {
publication: previous_dir,
receipt,
stale: &stale,
},
)?;
}
let realized = crate::install::execute(
&plan,
&publication,
&paths::cargo_build_cache(home),
crate::report::subprocess_output_visible(),
config.debug,
)?;
for step in &plan.steps {
if let Some(authority) = step.authority() {
crate::plan::recheck_path(authority)?;
}
}
fault::fail_at(fault::FaultPoint::PostStage)?;
crate::install::verify(&plan, &publication)?;
let receipt = crate::publish::receipt_for(
&plan,
&publication,
&publication_id,
&realized,
previous.as_ref().map(|(_, receipt)| receipt),
);
crate::publish::write_receipt(&publication, &receipt)?;
if let Some(installation) = entry.target_installation.as_mut() {
for component in &mut installation.components {
let rebuilt = plan.steps.iter().any(|step| {
step.owner() == component.name.as_ref() && realized.contains_key(step.dest())
});
if rebuilt {
refresh_path_modification_times(config, std::slice::from_mut(component));
} else if plan.steps.iter().any(|step| step.owner() == component.name.as_ref())
&& let Some(previous) = state.get_by_id(&installation_id).and_then(|previous| {
previous.components.iter().find(|old| old.name == component.name)
})
{
component.version = previous.version.clone();
}
}
}
crate::publish::journal::prepare(home, &entry)?;
fault::fail_at(fault::FaultPoint::PostVerify)?;
crate::publish::journal::commit_symlink(home, &entry)?;
fault::fail_at(fault::FaultPoint::PostCommit)?;
crate::publish::journal::record(home, &entry, state)?;
fault::fail_at(fault::FaultPoint::PostRecord)?;
if let Some(network) = &options.network
&& options.custom.is_none()
{
let relative_channel_target = PathBuf::from(format!("{}", channel.name));
let link = paths::network_link(home, network);
crate::trace!("linking {} -> {}", link.display(), relative_channel_target.display());
utils::fs::replace_symlink(&link, &relative_channel_target).with_context(|| {
format!("failed to point '{network}' at the newly installed channel")
})?;
}
fault::fail_at(fault::FaultPoint::PostDerive)?;
crate::publish::journal::clean(home, &entry)?;
match &installation_id {
InstallationId::Version(version) => crate::info!("installed channel '{version}'"),
InstallationId::Custom(_) => crate::info!("installed toolchain '{installation_id}'"),
}
Ok(())
}
pub(crate) fn effective_intent(
state: &LocalState,
channel: &Channel,
options: &InstallationOptions,
) -> Intent {
let requested = Intent {
profiles: [options.profile.unwrap_or_default()].into_iter().collect(),
roots: options.components.iter().cloned().collect(),
};
let previous = state
.get_by_id(&options.installation_id(&channel.name))
.map(|installation| installation.intent.clone());
match options.intent_update.clone() {
None => requested,
Some(IntentUpdate::Replace(intent)) => intent,
Some(IntentUpdate::Union(intent)) => {
let mut merged = previous.unwrap_or_default();
merged.union_with(&intent);
merged
},
Some(IntentUpdate::Preserve) => previous.unwrap_or(requested),
}
}
fn carry_migrated_intent(
state: &LocalState,
channel: &Channel,
id: &InstallationId,
intent: Intent,
) -> Intent {
use colored::Colorize;
let Some(migrated) = state.get_by_id(id).filter(|installation| !installation.is_managed())
else {
return intent;
};
let mut intent = intent;
intent.union_with(&migrated.intent);
let (kept, dropped): (Vec<String>, Vec<String>) = intent
.roots
.iter()
.cloned()
.partition(|root| channel.get_component(root).is_some());
if dropped.is_empty() {
return intent;
}
crate::report::prepare_stderr_color();
let listed = dropped.iter().map(|root| format!("\n- {}", root.bold())).collect::<String>();
crate::warn!(
"these components are no longer part of channel {} and have been dropped from your \
selection:{listed}",
channel.name
);
Intent {
profiles: intent.profiles,
roots: kept.into_iter().collect(),
}
}
fn target_installation(
config: &Config,
channel: &Channel,
intent: &Intent,
options: &InstallationOptions,
publication_id: &PublicationId,
plan: &crate::plan::InstallationPlan,
) -> anyhow::Result<Installation> {
let installed_components = {
let mut installed_components: Vec<crate::manifest::Component> =
crate::resolve::resolve(channel, intent)?.into_iter().cloned().collect();
for component in installed_components.iter_mut() {
match &component.version {
Authority::Git {
repository_url,
subpath,
target: GitTarget::Branch { name, .. },
} => {
let revision_hash = utils::git::find_latest_hash(repository_url, name).ok();
component.version = Authority::Git {
repository_url: repository_url.clone(),
subpath: subpath.clone(),
target: GitTarget::Branch {
name: name.clone(),
latest_revision: revision_hash,
},
}
},
Authority::Git { .. } | Authority::Path { .. } | Authority::Registry { .. } => (),
}
}
refresh_path_modification_times(config, &mut installed_components);
for component in installed_components.iter_mut() {
if let Some(held) = options.held_back.iter().find(|held| held.name == component.name) {
*component = held.clone();
}
}
installed_components
};
Ok(Installation {
channel: channel.name.clone(),
custom: options.custom.clone(),
intent: intent.clone(),
components: installed_components,
publication: PublicationRef::Managed {
id: publication_id.clone(),
plan_key: plan.key.clone(),
target: config.target().to_string(),
},
installed_at: chrono::Utc::now().timestamp(),
midenup_version: Some(
semver::Version::parse(env!("CARGO_PKG_VERSION"))
.expect("CARGO_PKG_VERSION is always valid semver"),
),
patches: options.patches.clone(),
})
}
fn refresh_path_modification_times(config: &Config, components: &mut [crate::manifest::Component]) {
for component in components.iter_mut() {
let Authority::Path { path, .. } = &component.version else {
continue;
};
let path = if path.is_absolute() {
Cow::Borrowed(path.as_path())
} else {
Cow::Owned(config.working_directory.join(path.as_path()))
};
let latest_time = utils::fs::latest_modification(&path)
.ok()
.map(|(latest_modification, _)| latest_modification)
.unwrap_or_else(SystemTime::now);
component.version = Authority::Path {
path: path.to_path_buf(),
last_modification: Some(latest_time),
};
}
}
fn previous_publication_id(state: &LocalState, id: &InstallationId) -> Option<PublicationId> {
match &state.get_by_id(id)?.publication {
PublicationRef::Managed { id, .. } => Some(id.clone()),
PublicationRef::NeedsReinstall => None,
}
}
fn previous_publication(
config: &Config,
state: &LocalState,
id: &InstallationId,
) -> Option<(PathBuf, crate::state::Receipt)> {
let installation = state.get_by_id(id)?;
let PublicationRef::Managed { id, .. } = &installation.publication else {
return None;
};
let dir = paths::publication_dir(&config.midenup_home, &installation.channel, id);
let receipt = crate::publish::read_receipt(&dir).ok()?;
Some((dir, receipt))
}
#[allow(unused)]
pub struct InstalledBinary {
pub version: semver::Version,
pub location: Authority,
pub bins: Vec<String>,
pub features: Vec<String>,
}
#[derive(Deserialize)]
struct CargoInstalls {
#[serde(default)]
installs: BTreeMap<String, InstalledCrateInfo>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum InstalledCrateInfo {
Info {
#[serde(default)]
bins: Vec<String>,
#[serde(default)]
features: Vec<String>,
},
UnknownFormat,
}
#[allow(unused)]
pub fn get_installed_cargo_binaries(
root_dir: PathBuf,
) -> anyhow::Result<HashMap<String, InstalledBinary>> {
let crates2_json = root_dir.join(".crates2.json");
if !crates2_json.exists() {
return Ok(HashMap::new());
}
let crates2_json_file = std::fs::File::open(&crates2_json).with_context(|| {
format!(
"failed to obtain binaries installed via Cargo from '{}'",
crates2_json.display()
)
})?;
let installs =
serde_json::from_reader::<_, CargoInstalls>(crates2_json_file).with_context(|| {
format!("failed to deserialize Cargo's install manifest '{}'", crates2_json.display())
})?;
let mut installed = HashMap::new();
for (crate_id, info) in installs.installs {
let InstalledCrateInfo::Info { bins, features } = info else {
continue;
};
if bins.is_empty() {
continue;
}
let Some((crate_name, rest)) = crate_id.split_once(' ') else {
continue;
};
let Some((crate_version, source)) = rest.split_once(' ') else {
continue;
};
let crate_name = crate_name.trim();
let Ok(crate_version) = semver::Version::parse(crate_version.trim()) else {
continue;
};
let source = source.trim().trim_matches(['(', ')']);
if let Some(path) = source.strip_prefix("path+") {
installed.insert(
crate_name.to_string(),
InstalledBinary {
version: crate_version,
location: Authority::Path {
path: PathBuf::from(path.to_string()),
last_modification: None,
},
bins,
features,
},
);
} else if source.starts_with("registry+") {
let version = crate_version.clone();
installed.insert(
crate_name.to_string(),
InstalledBinary {
version: crate_version,
location: Authority::Registry { version },
bins,
features,
},
);
}
}
Ok(installed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::CustomToolchain;
fn config(root: &std::path::Path) -> Config {
Config::init(root.to_path_buf(), root.join("home"), root.join("cargo"), "", true).unwrap()
}
fn virtual_channel(version: &str) -> Channel {
let component = serde_json::from_value(serde_json::json!({
"name": "node", "kind": "command",
"version": {"kind": "registry", "version": version},
"profiles": ["minimal"], "format": ["echo", "hello"]
}))
.unwrap();
Channel::new(semver::Version::new(0, 15, 0), vec![component])
}
fn named_options() -> InstallationOptions {
InstallationOptions {
custom: Some(CustomToolchain {
name: "dev".parse().unwrap(),
channel: "0.15.0".parse().unwrap(),
}),
..Default::default()
}
}
#[test]
fn a_virtual_component_records_its_new_authority_after_reinstall() {
let temp = tempdir::TempDir::new("virtual-reinstall").unwrap();
let config = config(temp.path());
let mut state = LocalState::default();
let options = named_options();
install(&config, &virtual_channel("0.1.0"), &mut state, &options).unwrap();
install(&config, &virtual_channel("0.2.0"), &mut state, &options).unwrap();
let persisted = LocalState::load(&paths::state_path(&config.midenup_home)).unwrap();
let component = &persisted.installations[0].components[0];
assert_eq!(
component.version,
Authority::Registry { version: semver::Version::new(0, 2, 0) }
);
}
#[test]
fn invalid_network_options_fail_before_creating_installation_files() {
let channel = virtual_channel("0.1.0");
for (network, custom) in [
("custom", false),
("Custom", false),
("default", false),
("../escape", false),
("mainnet", true),
] {
let temp = tempdir::TempDir::new("invalid-install-network").unwrap();
let config = config(temp.path());
let mut state = LocalState::default();
let mut options = if custom {
named_options()
} else {
InstallationOptions::default()
};
options.network = Some(network.into());
assert!(
install(&config, &channel, &mut state, &options).is_err(),
"accepted {network}"
);
assert!(!config.midenup_home.exists(), "created installation files for {network}");
assert!(!config.cargo_home.exists(), "created dispatch links for {network}");
assert!(state.installations.is_empty());
}
}
}