use std::fs::{self, File};
use std::path::{Path, PathBuf};
use super::{RECEIPT_NAME, STAGE_SCHEMA};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::held::{self, Identity, open_dir, proc_path};
pub const PAUSE_VAR: &str = "RK_STAGE_CLEAN_PAUSE_AFTER_VALIDATE";
pub const PAUSE_BEFORE_OPEN_VAR: &str = "RK_STAGE_CLEAN_PAUSE_BEFORE_OPEN";
pub const PAUSE_AFTER_QUARANTINE_VAR: &str = "RK_STAGE_CLEAN_PAUSE_AFTER_QUARANTINE";
const QUARANTINE_PREFIX: &str = ".rk-clean-quarantine-";
#[derive(Debug)]
pub struct Validated {
parent: File,
stage: File,
stage_identity: Identity,
name: std::ffi::OsString,
resolved: PathBuf,
target: String,
}
impl Validated {
#[must_use]
pub fn resolved(&self) -> &Path {
&self.resolved
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
}
fn refuse(message: String, expected: &str) -> RkError {
RkError::refusal(
Diagnostic::new(Reason::DestructiveRefusal, message)
.expected(expected)
.action("pass the path of one stage directory, as rk stage printed it")
.target_state("unchanged"),
)
}
pub fn validate(argument: &Path) -> Result<Validated, RkError> {
let (parent, name, resolved) = resolve(argument)?;
let metadata = judge_entry(argument, &resolved)?;
let target = judge_receipt(&resolved)?;
let parent_dir = open_dir(&parent)?;
let stage = open_dir(&proc_path(&parent_dir).join(&name))?;
let stage_identity = Identity::of(&stage.metadata()?);
if stage_identity != Identity::of(&metadata) {
return Err(refuse(
format!(
"{} changed while it was being validated",
resolved.display()
),
"a stage directory that stays put",
));
}
Ok(Validated {
parent: parent_dir,
stage,
stage_identity,
name,
resolved,
target,
})
}
fn resolve(argument: &Path) -> Result<(PathBuf, std::ffi::OsString, PathBuf), RkError> {
let name = argument
.file_name()
.filter(|name| *name != "." && *name != "..")
.ok_or_else(|| {
refuse(
format!("{} names no directory to remove", argument.display()),
"the path of one stage directory",
)
})?
.to_owned();
let parent = argument
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map_or_else(|| Path::new("."), |parent| parent);
let parent = fs::canonicalize(parent).map_err(|error| missing(argument, &error))?;
let resolved = parent.join(&name);
if resolved.parent().is_none() {
return Err(refuse(
"the filesystem root is never a stage".to_owned(),
"the path of one stage directory",
));
}
if let Some(home) = std::env::var_os("HOME")
.filter(|home| !home.is_empty())
.and_then(|home| fs::canonicalize(home).ok())
&& home == resolved
{
return Err(refuse(
format!(
"{} is the home directory, never a stage",
resolved.display()
),
"the path of one stage directory",
));
}
Ok((parent, name, resolved))
}
fn judge_entry(argument: &Path, resolved: &Path) -> Result<fs::Metadata, RkError> {
let metadata = match fs::symlink_metadata(resolved) {
Ok(metadata) => metadata,
Err(error) => return Err(missing(argument, &error)),
};
if metadata.file_type().is_symlink() {
return Err(refuse(
format!(
"{} is a symlink, and a link is never removed as a stage",
resolved.display()
),
"the stage directory itself, not a link to it",
));
}
if !metadata.is_dir() {
return Err(refuse(
format!("{} is not a directory", resolved.display()),
"the path of one stage directory",
));
}
if fs::symlink_metadata(resolved.join(".git")).is_ok() {
return Err(refuse(
format!("{} is a repository root, never a stage", resolved.display()),
"a stage directory, which carries no .git",
));
}
Ok(metadata)
}
#[derive(Debug, serde::Deserialize)]
struct SchemaOnly {
schema: String,
}
#[derive(Debug, serde::Deserialize)]
struct CleanReceipt {
stage_root: String,
target: String,
}
fn judge_receipt(resolved: &Path) -> Result<String, RkError> {
let receipt_path = resolved.join(RECEIPT_NAME);
let unparsed = |error: serde_json::Error| {
refuse(
format!(
"{} does not parse as a stage receipt: {error}",
receipt_path.display()
),
"a directory rk stage wrote, whose stage.json carries schema, stage_root, and target",
)
};
let bytes = match fs::read(&receipt_path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(refuse(
format!(
"{} carries no {RECEIPT_NAME}, so it is not a stage",
resolved.display()
),
"a directory rk stage wrote",
));
}
Err(error) => return Err(error.into()),
};
let declared: SchemaOnly = serde_json::from_slice(&bytes).map_err(unparsed)?;
if declared.schema != STAGE_SCHEMA {
return Err(RkError::refusal(
Diagnostic::new(
Reason::UnsupportedSchema,
format!(
"{} declares schema {:?}, and this binary removes only {STAGE_SCHEMA}",
receipt_path.display(),
declared.schema
),
)
.expected(format!("a receipt declaring {STAGE_SCHEMA}"))
.target_state("unchanged"),
));
}
let receipt: CleanReceipt = serde_json::from_slice(&bytes).map_err(unparsed)?;
if Path::new(&receipt.stage_root) != resolved {
return Err(refuse(
format!(
"{} names {:?} as its stage root, not {}",
receipt_path.display(),
receipt.stage_root,
resolved.display()
),
"a receipt whose stage_root is the directory it sits in",
));
}
let target = Path::new(&receipt.target);
if !is_canonical_shape(target) {
return Err(refuse(
format!(
"{} names {:?} as its target, which is not a canonical absolute path",
receipt_path.display(),
receipt.target
),
"a receipt whose target is the canonical absolute path rk stage recorded",
));
}
if let Ok(canonical) = fs::canonicalize(target)
&& canonical != target
{
return Err(refuse(
format!(
"{} names {:?} as its target, whose canonical path is {}",
receipt_path.display(),
receipt.target,
canonical.display()
),
"a receipt whose target is the canonical absolute path rk stage recorded",
));
}
if target.starts_with(resolved) {
return Err(refuse(
format!(
"{} is the target {} or an ancestor of it, never a stage",
resolved.display(),
receipt.target
),
"a stage directory outside the target it describes",
));
}
Ok(receipt.target)
}
fn is_canonical_shape(path: &Path) -> bool {
use std::path::Component;
let mut components = path.components();
if components.next() != Some(Component::RootDir) {
return false;
}
let mut any = false;
for component in components {
if !matches!(component, Component::Normal(_)) {
return false;
}
any = true;
}
any
}
fn missing(argument: &Path, error: &std::io::Error) -> RkError {
if error.kind() == std::io::ErrorKind::NotFound {
RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("{} does not exist", argument.display()),
)
.expected("the path of one stage directory, as rk stage printed it")
.target_state("unchanged"),
)
} else {
RkError::Io(std::io::Error::new(error.kind(), error.to_string()))
}
}
fn swapped(path: &Path, detail: &str) -> std::io::Error {
swapped_leaving(
path,
detail,
"the removal stopped there, and nothing outside the validated stage was touched",
)
}
fn swapped_leaving(path: &Path, detail: &str, aftermath: &str) -> std::io::Error {
std::io::Error::other(format!(
"{} {detail} while the stage was being removed; {aftermath}",
path.display()
))
}
pub fn remove(validated: &Validated) -> Result<(), RkError> {
held::pause(PAUSE_VAR, "validated", "proceed");
let shown = validated.resolved.as_path();
remove_contents(&validated.stage, shown)?;
let (quarantined, current) = match held::quarantine(
&validated.parent,
&validated.name,
QUARANTINE_PREFIX,
) {
Ok(moved) => moved,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(RkError::Io(swapped_leaving(
shown,
"vanished from its parent",
"the validated directory's contents were removed through the held descriptor, and nothing else was touched",
)));
}
Err(error) => return Err(error.into()),
};
if current.file_type().is_symlink()
|| !current.is_dir()
|| Identity::of(¤t) != validated.stage_identity
{
return Err(RkError::Io(swapped_leaving(
shown,
"was replaced",
&format!(
"the validated directory's contents were removed, and the entry that stood at its path was moved to {} beside it and left alone",
quarantined.display()
),
)));
}
pause_after_quarantine(&validated.name);
let entry = proc_path(&validated.parent).join(&quarantined);
fs::remove_dir(&entry).map_err(|error| {
if error.kind() == std::io::ErrorKind::DirectoryNotEmpty {
swapped_leaving(
shown,
"gained entries after it was emptied",
&format!(
"the validated directory was moved to {} beside its path and left alone with what it gained",
quarantined.display()
),
)
} else {
error
}
})?;
Ok(())
}
fn remove_contents(dir: &File, shown: &Path) -> std::io::Result<()> {
let base = proc_path(dir);
let vanished = |error: std::io::Error, name: &std::ffi::OsStr| {
if error.kind() == std::io::ErrorKind::NotFound {
swapped(&shown.join(name), "vanished")
} else {
error
}
};
let names: Vec<std::ffi::OsString> = fs::read_dir(&base)?
.map(|entry| entry.map(|entry| entry.file_name()))
.collect::<std::io::Result<_>>()?;
for name in names {
let path = base.join(&name);
let checked = fs::symlink_metadata(&path).map_err(|error| vanished(error, &name))?;
let expected = if checked.is_dir() {
pause_before_open(&name);
let sub = open_dir(&path).map_err(|error| vanished(error, &name))?;
let opened = Identity::of(&sub.metadata()?);
if opened != Identity::of(&checked) {
return Err(swapped(
&shown.join(&name),
"was exchanged for another directory",
));
}
remove_contents(&sub, &shown.join(&name))?;
opened
} else {
Identity::of(&checked)
};
let (quarantined, current) = held::quarantine(dir, &name, QUARANTINE_PREFIX)
.map_err(|error| vanished(error, &name))?;
if current.is_dir() != checked.is_dir()
|| current.file_type().is_symlink() != checked.file_type().is_symlink()
|| Identity::of(¤t) != expected
{
return Err(swapped_leaving(
&shown.join(&name),
"was exchanged for another entry",
&format!(
"the entry that took its name was moved to {} inside the stage and left alone",
quarantined.display()
),
));
}
pause_after_quarantine(&name);
let held_entry = base.join(&quarantined);
if current.is_dir() {
fs::remove_dir(&held_entry).map_err(|error| {
if error.kind() == std::io::ErrorKind::DirectoryNotEmpty {
swapped(&shown.join(&name), "gained entries after it was emptied")
} else {
error
}
})?;
} else {
fs::remove_file(&held_entry)?;
}
}
Ok(())
}
fn pause_before_open(name: &std::ffi::OsStr) {
if std::env::var_os(PAUSE_BEFORE_OPEN_VAR).is_some_and(|wanted| wanted == name) {
held::pause(PAUSE_VAR, "checked", "proceed-checked");
}
}
fn pause_after_quarantine(name: &std::ffi::OsStr) {
if std::env::var_os(PAUSE_AFTER_QUARANTINE_VAR).is_some_and(|wanted| wanted == name) {
held::pause(PAUSE_VAR, "quarantined", "proceed-quarantined");
}
}
#[cfg(test)]
mod tests {
use super::{STAGE_SCHEMA, remove, validate};
use crate::diagnostic::Reason;
use crate::error::RkError;
fn reason_of(error: &RkError) -> Reason {
error.reason()
}
#[test]
fn a_directory_that_does_not_name_itself_refuses() {
let scratch = tempfile::tempdir().expect("a scratch dir exists");
let bare = scratch.path().join("bare");
std::fs::create_dir(&bare).expect("creates");
assert_eq!(
reason_of(&validate(&bare).expect_err("refuses")),
Reason::DestructiveRefusal
);
let other = scratch.path().join("other");
std::fs::create_dir(&other).expect("creates");
std::fs::write(other.join("stage.json"), r#"{"schema":"rk.other/1"}"#).expect("writes");
assert_eq!(
reason_of(&validate(&other).expect_err("refuses")),
Reason::UnsupportedSchema
);
let moved = scratch.path().join("moved");
std::fs::create_dir(&moved).expect("creates");
std::fs::write(
moved.join("stage.json"),
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"/nowhere"}}"#,
other.display()
),
)
.expect("writes");
assert_eq!(
reason_of(&validate(&moved).expect_err("refuses")),
Reason::DestructiveRefusal
);
assert!(bare.is_dir() && other.is_dir() && moved.is_dir());
}
#[test]
fn a_malformed_receipt_refuses_before_anything_is_removed() {
let scratch = tempfile::tempdir().expect("a scratch dir exists");
let canonical = std::fs::canonicalize(scratch.path()).expect("canonical");
let candidate = canonical.join("candidate");
std::fs::create_dir_all(candidate.join("repo/.git")).expect("creates");
std::fs::write(candidate.join("repo/precious"), b"keep").expect("writes");
let receipt = candidate.join("stage.json");
for body in [
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}"}}"#,
candidate.display()
),
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":null}}"#,
candidate.display()
),
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":""}}"#,
candidate.display()
),
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"repo"}}"#,
candidate.display()
),
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"{}/../candidate/repo"}}"#,
candidate.display(),
candidate.display()
),
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"{}/repo/"}}"#,
candidate.display(),
candidate.display()
),
] {
std::fs::write(&receipt, &body).expect("writes");
let error = validate(&candidate).expect_err("refuses");
assert_eq!(reason_of(&error), Reason::DestructiveRefusal, "{body}");
assert_eq!(
std::fs::read(candidate.join("repo/precious")).expect("reads"),
b"keep",
"{body}"
);
}
let link = canonical.join("link");
std::os::unix::fs::symlink(candidate.join("repo"), &link).expect("links");
std::fs::write(
&receipt,
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"{}"}}"#,
candidate.display(),
link.display()
),
)
.expect("writes");
assert_eq!(
reason_of(&validate(&candidate).expect_err("refuses")),
Reason::DestructiveRefusal
);
assert!(candidate.join("repo/.git").is_dir());
}
#[test]
fn a_valid_stage_is_removed_and_its_siblings_stand() {
let scratch = tempfile::tempdir().expect("a scratch dir exists");
let canonical = std::fs::canonicalize(scratch.path()).expect("canonical");
let stage = canonical.join("stage");
std::fs::create_dir_all(stage.join("artifacts/deep")).expect("creates");
std::fs::write(stage.join("artifacts/deep/file"), b"x").expect("writes");
std::fs::write(
stage.join("stage.json"),
format!(
r#"{{"schema":"{STAGE_SCHEMA}","stage_root":"{}","target":"/nowhere"}}"#,
stage.display()
),
)
.expect("writes");
let sibling = canonical.join("sibling");
std::fs::write(&sibling, b"keep").expect("writes");
let validated = validate(&stage).expect("validates");
remove(&validated).expect("removes");
assert!(!stage.exists());
assert_eq!(std::fs::read(&sibling).expect("reads"), b"keep");
}
}