use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Result;
use super::{exit, *};
use crate::state::{self, State};
use cartog::time_fmt::rfc3339_now;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateMode {
Check,
Defer(Option<String>),
ApplyPending,
Now,
}
impl UpdateMode {
#[must_use]
pub fn from_flags(check: bool, defer: bool, to: Option<String>, apply_pending: bool) -> Self {
match (check, defer, apply_pending) {
(true, _, _) => Self::Check,
(_, true, _) => Self::Defer(to),
(_, _, true) => Self::ApplyPending,
_ => Self::Now,
}
}
}
pub fn cmd_self_update(mode: UpdateMode, quiet: bool, json: bool) -> Result<()> {
let exit_code = match mode {
UpdateMode::Check => run_check(quiet, json),
UpdateMode::Defer(target) => run_arm(target.as_deref(), quiet, json),
UpdateMode::ApplyPending => run_apply_pending(quiet, json),
UpdateMode::Now => run_upgrade(quiet, json),
};
std::process::exit(exit_code);
}
fn run_check(quiet: bool, json: bool) -> i32 {
let api_url = github_latest_url();
match fetch_latest_version(&api_url) {
Ok(latest) => {
let outcome = CheckOutcome::ok(env!("CARGO_PKG_VERSION"), &latest);
if !quiet {
emit_check_outcome(&outcome, json);
}
if outcome.outdated == Some(true) {
1
} else {
0
}
}
Err(e) => {
if !quiet {
let outcome = CheckOutcome::failed(env!("CARGO_PKG_VERSION"), &e.to_string());
emit_check_outcome(&outcome, json);
}
2
}
}
}
fn emit_check_outcome(outcome: &CheckOutcome, json: bool) {
if json {
println!(
"{}",
serde_json::to_string(outcome).expect("CheckOutcome serialises")
);
} else {
println!("{}", outcome.to_human());
}
}
pub fn cmd_self_rollback() -> Result<()> {
let exit_code = run_rollback();
std::process::exit(exit_code);
}
fn run_rollback() -> i32 {
let current_bin = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("cartog: cannot resolve current exe: {e}");
return 2;
}
};
let backup_path = backup_path_for(¤t_bin);
if !backup_path.exists() {
eprintln!(
"cartog: no previous binary to roll back to (looked for {})",
backup_path.display(),
);
return 1;
}
let install_dir = match current_bin.parent() {
Some(p) => p,
None => {
eprintln!(
"cartog: current exe {} has no parent directory",
current_bin.display(),
);
return 2;
}
};
let intermediate = install_dir.join(format!(".cartog.broken.{}.tmp", std::process::id()));
if let Err(e) = self_update::Move::from_source(&backup_path)
.replace_using_temp(&intermediate)
.to_dest(¤t_bin)
{
eprintln!("cartog: rollback failed: {e}");
return 2;
}
if let Err(e) = std::fs::remove_file(&intermediate) {
tracing::warn!(
error = %e,
path = %intermediate.display(),
"rollback succeeded but failed to clean up staged broken binary",
);
}
println!(
"cartog: rolled back to previous binary ({})",
current_bin.display(),
);
0
}
fn run_upgrade(quiet: bool, json: bool) -> i32 {
let source = effective_install_source();
if source == "cargo" {
emit_upgrade_message(
quiet,
json,
"cargo",
"cartog was installed via cargo. Run `cargo install cartog --force` to upgrade.",
);
return exit::CARGO_INSTALL_REFUSED;
}
if let Some(dir) = state::default_state_dir() {
let active = cartog_process_lock::find_active_locks(&dir);
if let Some(peer) = active.first() {
emit_upgrade_message(
quiet,
json,
"peer-running",
&format!(
"another cartog process is running ({slot}, PID {pid}); stop it before updating",
slot = peer.slot,
pid = peer.pid,
),
);
return exit::PEER_RUNNING;
}
}
let api_url = github_latest_url();
let latest = match fetch_latest_version(&api_url) {
Ok(v) => v,
Err(e) => {
emit_upgrade_message(quiet, json, "fetch-failed", &e.to_string());
return exit::NETWORK_OR_PARSE_ERROR;
}
};
let current = env!("CARGO_PKG_VERSION");
if compare_stable_versions(current, &latest) != std::cmp::Ordering::Less {
if !quiet {
if json {
let payload = serde_json::json!({
"status": "up-to-date",
"current": current,
"latest": latest,
});
println!("{payload}");
} else {
println!("cartog: already up to date ({current})");
}
}
return exit::SUCCESS;
}
match perform_upgrade(current, &latest, quiet, json) {
Ok(()) => exit::SUCCESS,
Err(UpgradeError::Network(msg)) => {
emit_upgrade_message(quiet, json, "fetch-failed", &msg);
exit::NETWORK_OR_PARSE_ERROR
}
Err(UpgradeError::Checksum(msg)) => {
emit_upgrade_message(quiet, json, "checksum-failed", &msg);
exit::CHECKSUM_FAILED
}
Err(UpgradeError::Smoke(msg)) | Err(UpgradeError::Filesystem(msg)) => {
emit_upgrade_message(quiet, json, "filesystem-failed", &msg);
exit::DISK_OR_PERMISSION_FAILED
}
}
}
pub(crate) enum UpgradeError {
Network(String),
Checksum(String),
Filesystem(String),
Smoke(String),
}
const APPLY_PEER_WAIT: Duration = Duration::from_secs(10);
const APPLY_PEER_POLL: Duration = Duration::from_millis(200);
pub(crate) const APPLY_LOCK_SLOT: &str = "apply-update";
const TEST_APPLY_PEER_WAIT_MS_ENV: &str = "CARTOG_TEST_APPLY_PEER_WAIT_MS";
fn apply_peer_wait() -> Duration {
match std::env::var(TEST_APPLY_PEER_WAIT_MS_ENV) {
Ok(v) => v
.parse::<u64>()
.map(Duration::from_millis)
.unwrap_or(APPLY_PEER_WAIT),
Err(_) => APPLY_PEER_WAIT,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApplyDecision {
Skip,
Proceed,
}
pub(crate) fn decide_apply(current: &str, target: &str) -> ApplyDecision {
if !is_stable_semver(target) {
return ApplyDecision::Skip;
}
if compare_stable_versions(current, target) == std::cmp::Ordering::Less {
ApplyDecision::Proceed
} else {
ApplyDecision::Skip
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IntentDisposition {
Keep,
Clear,
}
pub(crate) fn intent_disposition(err: &UpgradeError) -> IntentDisposition {
match err {
UpgradeError::Network(_) | UpgradeError::Filesystem(_) => IntentDisposition::Keep,
UpgradeError::Checksum(_) | UpgradeError::Smoke(_) => IntentDisposition::Clear,
}
}
pub(crate) fn wait_for_no_peer(
state_dir: &Path,
budget: Duration,
) -> std::result::Result<(), cartog_process_lock::ActiveLock> {
let deadline = std::time::Instant::now() + budget;
loop {
let mut active = cartog_process_lock::find_active_locks(state_dir);
active.retain(|lock| lock.slot != APPLY_LOCK_SLOT);
match active.into_iter().next() {
None => return Ok(()),
Some(peer) => {
if std::time::Instant::now() >= deadline {
return Err(peer);
}
std::thread::sleep(APPLY_PEER_POLL);
}
}
}
}
fn run_arm(target_override: Option<&str>, quiet: bool, json: bool) -> i32 {
if effective_install_source() == "cargo" {
emit_upgrade_message(
quiet,
json,
"cargo",
"cartog was installed via cargo. Run `cargo install cartog --force` to upgrade.",
);
return exit::CARGO_INSTALL_REFUSED;
}
let latest = match target_override {
Some(v) => {
if !is_stable_semver(v) {
emit_upgrade_message(
quiet,
json,
"invalid-version",
&format!("`--to {v}` is not a MAJOR.MINOR.PATCH version"),
);
return exit::NETWORK_OR_PARSE_ERROR;
}
v.to_string()
}
None => {
let api_url = github_latest_url();
match fetch_latest_version(&api_url) {
Ok(v) => v,
Err(e) => {
emit_upgrade_message(quiet, json, "fetch-failed", &e.to_string());
return exit::NETWORK_OR_PARSE_ERROR;
}
}
}
};
let current = env!("CARGO_PKG_VERSION");
if compare_stable_versions(current, &latest) != std::cmp::Ordering::Less {
if let Some(state_path) = state::default_state_file() {
let mut state = State::load_from(&state_path);
if state.pending_update.take().is_some() {
let _ = state.save_to(&state_path);
}
}
if !quiet {
if json {
let payload = serde_json::json!({
"status": "up-to-date",
"current": current,
"latest": latest,
});
println!("{payload}");
} else {
println!("cartog: already up to date ({current})");
}
}
return exit::SUCCESS;
}
let Some(state_path) = state::default_state_file() else {
emit_upgrade_message(
quiet,
json,
"filesystem-failed",
"cannot resolve state file to record the deferred update",
);
return exit::DISK_OR_PERMISSION_FAILED;
};
let mut state = State::load_from(&state_path);
state.pending_update = Some(crate::state::PendingUpdate {
target_version: latest.clone(),
armed_from: Some(current.to_string()),
armed_at: Some(rfc3339_now()),
});
state.last_known_latest = Some(latest.clone());
state.last_known_outdated = true;
state.last_update_check = Some(rfc3339_now());
if let Err(e) = state.save_to(&state_path) {
emit_upgrade_message(
quiet,
json,
"filesystem-failed",
&format!("failed to persist deferred update intent: {e}"),
);
return exit::DISK_OR_PERMISSION_FAILED;
}
if !quiet {
if json {
let payload = serde_json::json!({
"status": "armed",
"current": current,
"target": latest,
"apply": "session-end-or-restart",
});
println!("{payload}");
} else {
println!(
"cartog: armed update {current} -> {latest}; it will be applied when the \
current session ends (or run `cartog self update` from a terminal with no \
cartog serve/watch running)."
);
}
}
exit::SUCCESS
}
fn run_apply_pending(quiet: bool, json: bool) -> i32 {
let Some(state_path) = state::default_state_file() else {
return exit::SUCCESS;
};
let mut state = State::load_from(&state_path);
let Some(pending) = state.pending_update.clone() else {
return exit::SUCCESS; };
let target = pending.target_version;
let current = env!("CARGO_PKG_VERSION");
if effective_install_source() == "cargo" {
state.pending_update = None;
let _ = state.save_to(&state_path);
emit_upgrade_message(
quiet,
json,
"cargo",
"cartog was installed via cargo. Run `cargo install cartog --force` to upgrade.",
);
return exit::CARGO_INSTALL_REFUSED;
}
if decide_apply(current, &target) == ApplyDecision::Skip {
state.pending_update = None;
let _ = state.save_to(&state_path);
if !quiet && !json {
println!("cartog: already at {current}; cleared stale deferred update ({target}).");
}
return exit::SUCCESS;
}
if let Some(dir) = state::default_state_dir() {
if let Err(peer) = wait_for_no_peer(&dir, apply_peer_wait()) {
emit_upgrade_message(
quiet,
json,
"peer-running",
&format!(
"another cartog process is still running ({slot}, PID {pid}); \
deferred update kept and will retry next session",
slot = peer.slot,
pid = peer.pid,
),
);
return exit::PEER_RUNNING;
}
}
let _apply_lock = match state::default_state_dir() {
Some(dir) => match cartog_process_lock::ProcessLock::acquire(&dir, APPLY_LOCK_SLOT) {
Ok(lock) => Some(lock),
Err(cartog_process_lock::AcquireError::Held(_)) => {
if !quiet && !json {
println!("cartog: another deferred update is already in progress; skipping.");
}
return exit::SUCCESS;
}
Err(cartog_process_lock::AcquireError::Io(e)) => {
tracing::warn!(error = %e, "apply-update lock unavailable; proceeding unserialized");
None
}
},
None => None,
};
let err = match perform_upgrade(current, &target, quiet, json) {
Ok(()) => {
let mut state = State::load_from(&state_path);
state.pending_update = None;
let _ = state.save_to(&state_path);
write_last_update_breadcrumb(&target);
return exit::SUCCESS;
}
Err(e) => e,
};
if intent_disposition(&err) == IntentDisposition::Clear {
let mut state = State::load_from(&state_path);
state.pending_update = None;
let _ = state.save_to(&state_path);
}
match err {
UpgradeError::Network(msg) => {
emit_upgrade_message(quiet, json, "fetch-failed", &msg);
exit::NETWORK_OR_PARSE_ERROR
}
UpgradeError::Checksum(msg) => {
emit_upgrade_message(quiet, json, "checksum-failed", &msg);
exit::CHECKSUM_FAILED
}
UpgradeError::Smoke(msg) => {
emit_upgrade_message(
quiet,
json,
"smoke-failed",
&format!(
"{msg}. This target failed verification and will not be retried \
automatically; run `cartog self update` from a terminal or reinstall \
via /cartog-install."
),
);
exit::SMOKE_TEST_FAILED
}
UpgradeError::Filesystem(msg) => {
emit_upgrade_message(quiet, json, "filesystem-failed", &msg);
exit::DISK_OR_PERMISSION_FAILED
}
}
}
pub(crate) fn write_last_update_breadcrumb(target: &str) {
let Some(path) = last_update_breadcrumb_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, format!("cartog updated to {target}.\n"));
}
pub(crate) fn last_update_breadcrumb_path() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("CARTOG_LOG_DIR") {
if !dir.is_empty() {
return Some(PathBuf::from(dir).join("last-update"));
}
}
let base = match std::env::var_os("XDG_CACHE_HOME") {
Some(v) if !v.is_empty() => PathBuf::from(v),
_ => {
let home = std::env::var_os("HOME")?;
PathBuf::from(home).join(".cache")
}
};
Some(base.join("cartog").join("last-update"))
}