use std::path::{Path, PathBuf};
use cap_std::fs::Dir;
use serde::{Deserialize, Serialize};
use super::{Software, cleanup, launch, ownership::Receipt, records};
use crate::setup_core::{Result, software_prefix::INVENTORY_LIMIT};
use records::{Identity, io, leaf, open_root, present, refuse, sync, unique};
const RECORD_LIMIT: usize = INVENTORY_LIMIT + 64 * 1024;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum Phase {
Prepared,
Quarantined,
Cleaned,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Record {
schema_version: u32,
command: String,
version: String,
quarantine: String,
root_identity: Identity,
receipt: Receipt,
exposure: Option<launch::Record>,
phase: Phase,
#[serde(default)]
operation: Option<super::operation::Binding>,
}
pub(super) struct Removal {
root: Dir,
path: PathBuf,
journal: super::store::Key,
record: Record,
}
pub(super) fn journal(command: &str) -> super::store::Key {
super::store::Key::Removal(command.to_owned())
}
impl Removal {
pub(super) fn begin(path: &Path, software: &Software) -> Result<Option<Self>> {
Self::begin_for(path, software, None)
}
pub(super) fn begin_for(
path: &Path,
software: &Software,
operation: Option<&super::operation::Binding>,
) -> Result<Option<Self>> {
super::require_idle_for(software, path, operation)?;
let root = open_root(path)?;
let Some(directory) = present(&root, software.version)? else {
return Ok(None);
};
let receipt = Receipt::read(&root, software.command, software.version)?.ok_or_else(|| {
crate::setup_core::Error::new(crate::setup_core::ReasonCode::RecoveryRequired,
"software version has no installation receipt; reinstall its exact artifact to adopt unchanged legacy bytes")
})?;
receipt.verify(&directory)?;
drop(directory);
let exposure =
launch::Retraction::prepare(path, software)?.map(launch::Retraction::into_record);
let removal = Self {
record: Record {
schema_version: 2,
command: software.command.to_owned(),
version: software.version.to_owned(),
quarantine: format!(".removing-{}-{}", software.command, unique()?),
root_identity: Identity::of(&root)?,
receipt,
exposure,
phase: Phase::Prepared,
operation: operation.cloned(),
},
root,
path: path.to_owned(),
journal: journal(software.command),
};
removal.save()?;
Ok(Some(removal))
}
pub(super) fn load(path: &Path, command: &str) -> Result<Option<Self>> {
if !leaf(command) {
return Err(refuse());
}
let Some(root) = records::optional_root(path)? else {
return Ok(None);
};
let Some(record): Option<Record> =
super::store::read(&root, &journal(command), RECORD_LIMIT)?
else {
return Ok(None);
};
if !matches!(record.schema_version, 1 | 2)
|| (record.schema_version == 1 && record.operation.is_some())
|| record.command != command
|| !leaf(&record.version)
|| !leaf(&record.quarantine)
|| !record
.quarantine
.starts_with(&format!(".removing-{command}-"))
|| record.root_identity != Identity::of(&root)?
{
return Err(refuse());
}
record.receipt.validate(&root, command, &record.version)?;
if let Some(binding) = &record.operation {
binding.validate()?;
}
Ok(Some(Self {
root,
path: path.to_owned(),
journal: journal(command),
record,
}))
}
pub(super) fn version(&self) -> &str {
&self.record.version
}
pub(super) fn binding(&self) -> Option<&super::operation::Binding> {
self.record.operation.as_ref()
}
pub(super) fn recorded_paths(&self) -> Vec<String> {
vec![self.record.quarantine.clone()]
}
fn check_root(&self) -> Result<()> {
if Identity::of(&open_root(&self.path)?)? != self.record.root_identity {
return Err(refuse());
}
super::store::check_active(&self.root, self.record.operation.as_ref())
}
fn save(&self) -> Result<()> {
self.check_root()?;
super::store::write(&self.root, &self.journal, &self.record, RECORD_LIMIT)
}
fn quarantine(&mut self) -> Result<()> {
let current = present(&self.root, &self.record.version)?;
let quarantined = present(&self.root, &self.record.quarantine)?;
match (current, quarantined) {
(Some(directory), None) => {
self.record.receipt.verify(&directory)?;
drop(directory);
io(self
.root
.rename(&self.record.version, &self.root, &self.record.quarantine))?;
let directory = present(&self.root, &self.record.quarantine)?.ok_or_else(refuse)?;
self.record.receipt.verify(&directory)?;
sync(&self.root)?;
}
(None, Some(directory)) => {
self.record.receipt.verify(&directory)?;
}
_ => return Err(refuse()),
}
self.record.phase = Phase::Quarantined;
self.save()
}
pub(super) fn complete(mut self) -> Result<()> {
self.check_root()?;
let receipt = Receipt::read(&self.root, &self.record.command, &self.record.version)?;
if receipt.as_ref() != Some(&self.record.receipt)
&& !(receipt.is_none() && self.record.phase == Phase::Cleaned)
{
return Err(refuse());
}
if self.record.phase == Phase::Prepared {
self.quarantine()?;
}
if present(&self.root, &self.record.version)?.is_some() {
return Err(refuse());
}
let remaining = present(&self.root, &self.record.quarantine)?;
if remaining
.as_ref()
.map(Identity::of)
.transpose()?
.is_some_and(|identity| identity != self.record.receipt.tree_identity())
{
return Err(refuse());
}
if self.record.phase == Phase::Cleaned {
if remaining.is_some() {
return Err(refuse());
}
} else {
let exposure = self
.record
.exposure
.as_ref()
.map(|exposure| {
launch::Retraction::resume(
&self.path,
&self.record.command,
&self.record.version,
self.record.receipt.member(),
exposure.clone(),
)
})
.transpose()?;
if let Some(directory) = remaining {
cleanup::remaining(&directory, self.record.receipt.entries())?;
self.check_root()?;
io(directory.remove_open_dir())?;
sync(&self.root)?;
}
if let Some(exposure) = exposure {
exposure.complete()?;
}
self.record.phase = Phase::Cleaned;
self.save()?;
}
self.record.receipt.remove_record(&self.root)?;
self.check_root()?;
if let Some(binding) = &self.record.operation {
super::store::complete(
&self.root,
binding,
Some(&self.journal),
&super::operation::Outcome::Removed { removed: true },
)?;
} else {
super::store::remove(&self.root, &self.journal)?;
}
sync(&self.root)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use crate::setup_core::{
archive::build::{Dialect, Item, gzip_tar},
digest,
software::{self, Artifact, Delivery, Shape},
};
use std::fs;
#[test]
fn interrupted_removal_preserves_foreign_entries_and_resumes_owned_subsets() {
let at = std::env::temp_dir().join(format!("software-removal-{}", unique().unwrap()));
fs::create_dir(&at).unwrap();
let root = at.join("prefix");
let bytes = gzip_tar(
&[
Item::file("codex", b"payload", 0o755),
Item::file("docs/a", b"first", 0o644),
Item::file("docs/b", b"second", 0o644),
],
Dialect::Gnu,
);
let archive = at.join("artifact.tgz");
fs::write(&archive, &bytes).unwrap();
let artifact = Artifact {
platform: "linux/x86_64",
url: "https://example.invalid/tool.tgz",
bytes: bytes.len() as u64,
sha256: Box::leak(digest::of_bytes(&bytes).into_boxed_str()),
shape: Shape::GzipTar,
member: "codex",
};
let declared = Software {
version: "1.2.3",
command: "codex",
delivery: Delivery::Artifacts(&[]),
unsupported: &[],
previous: None,
};
for interruption in 0..4 {
software::install(&declared, &artifact, &archive, &root).unwrap();
let mut removal = Removal::begin(&root, &declared).unwrap().unwrap();
let quarantine = root.join(&removal.record.quarantine);
assert!(software::remove(&declared, &root).is_err());
assert!(software::install(&declared, &artifact, &archive, &root).is_err());
if interruption > 0 {
removal.quarantine().unwrap();
}
if interruption == 1 {
fs::remove_file(quarantine.join("docs/a")).unwrap();
fs::remove_file(root.join("bin/.codex.version")).unwrap();
fs::write(quarantine.join("personal"), b"preserve").unwrap();
assert!(software::recover(&declared, &root).is_err());
assert_eq!(fs::read(quarantine.join("docs/b")).unwrap(), b"second");
assert_eq!(fs::read(quarantine.join("personal")).unwrap(), b"preserve");
fs::remove_file(quarantine.join("personal")).unwrap();
fs::write(quarantine.join("docs/b"), b"changed").unwrap();
assert!(software::recover(&declared, &root).is_err());
assert_eq!(fs::read(quarantine.join("docs/b")).unwrap(), b"changed");
fs::write(quarantine.join("docs/b"), b"second").unwrap();
} else if interruption == 2 {
let moved = root.join("retained-tree");
fs::rename(&quarantine, &moved).unwrap();
fs::create_dir(&quarantine).unwrap();
fs::write(quarantine.join("personal"), b"foreign directory").unwrap();
assert!(software::recover(&declared, &root).is_err());
assert_eq!(
fs::read(quarantine.join("personal")).unwrap(),
b"foreign directory"
);
fs::remove_file(quarantine.join("personal")).unwrap();
fs::remove_dir(&quarantine).unwrap();
fs::rename(moved, &quarantine).unwrap();
} else if interruption == 3 {
let directory = present(&removal.root, &removal.record.quarantine)
.unwrap()
.unwrap();
cleanup::remaining(&directory, removal.record.receipt.entries()).unwrap();
directory.remove_open_dir().unwrap();
launch::Retraction::resume(
&root,
"codex",
"1.2.3",
"codex",
removal.record.exposure.clone().unwrap(),
)
.unwrap()
.complete()
.unwrap();
removal.record.phase = Phase::Cleaned;
removal.save().unwrap();
removal.record.receipt.remove_record(&removal.root).unwrap();
}
drop(removal);
assert_eq!(software::recover(&declared, &root).unwrap().len(), 1);
assert!(software::recover(&declared, &root).unwrap().is_empty());
software::require_idle(&declared, &root).unwrap();
assert!(!root.join("1.2.3").exists() && !quarantine.exists());
assert!(
!super::super::store::exists(
&open_root(&root).unwrap(),
&super::super::store::Key::Receipt {
command: "codex".into(),
version: "1.2.3".into()
}
)
.unwrap()
);
assert_eq!(fs::read_dir(root.join("bin")).unwrap().count(), 0);
}
fs::remove_dir_all(at).unwrap();
}
}