use std::ffi::OsStr;
use std::fs::File;
use std::path::Path;
use camino::Utf8Path;
use serde::Serialize;
use super::manifest::{self, FileRecord, Manifest, Parameters};
use super::{Kind, Params, lock};
use crate::config;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
use crate::held;
use crate::projection::{Candidate, Placement, Projection, ProjectionInput, TargetEvidence};
pub const INTERRUPT_VAR: &str = "RK_APPLY_INTERRUPT_AT";
pub const PAUSE_VAR: &str = "RK_APPLY_PAUSE_DIR";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Created,
Replaced,
Matched,
Preserved,
Drift,
Released,
}
impl Action {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Created => "created",
Self::Replaced => "replaced",
Self::Matched => "matched",
Self::Preserved => "preserved",
Self::Drift => "drift",
Self::Released => "released",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Decision {
pub destination: String,
pub kind: Kind,
pub action: Action,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Collision {
pub path: String,
pub reason: String,
}
#[derive(Debug)]
pub struct Held {
root: File,
base: camino::Utf8PathBuf,
display: camino::Utf8PathBuf,
}
impl Held {
pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
let root = held::open_dir(target.as_std_path())?;
let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
.map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
Ok(Self {
root,
base,
display: target.to_owned(),
})
}
pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
let held = Self::open(target)?;
let opened = held::Identity::of(&held.root.metadata()?);
if lock.identity() != opened {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"the directory at {target} was exchanged after the lock was taken, and nothing was written"
),
)
.expected("one directory at the target path from the lock through the receipt write")
.action("re-run once the target is at rest")
.target_state("unchanged"),
));
}
Ok(held)
}
#[must_use]
pub fn base(&self) -> &Utf8Path {
&self.base
}
#[must_use]
pub fn display(&self) -> &Utf8Path {
&self.display
}
}
#[derive(Debug)]
pub struct Prepared {
pub params: Params,
pub projection: Projection,
pub decisions: Vec<Decision>,
pub collisions: Vec<Collision>,
pub config: config::Plan,
}
impl Prepared {
#[must_use]
pub fn decision(&self, destination: &str) -> Option<&Decision> {
self.decisions
.iter()
.find(|decision| decision.destination == destination)
}
}
pub fn prepare(
target: &Held,
recorded: Option<&Manifest>,
params: &Params,
existing_config: Option<&config::Config>,
) -> Result<Prepared, RkError> {
let evidence = TargetEvidence::gather(target.base(), recorded)?;
let projection = Projection::compute(&ProjectionInput {
params: params.clone(),
evidence,
})?;
let (decisions, collisions) = decide(target, recorded, &projection)?;
let config = config::Plan::new(
target.base().as_std_path(),
params,
existing_config,
recorded,
)?;
Ok(Prepared {
params: params.clone(),
projection,
decisions,
collisions,
config,
})
}
pub fn decide(
target: &Held,
recorded: Option<&Manifest>,
projection: &Projection,
) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
let root = &target.root;
let mut decisions = Vec::new();
let mut collisions: Vec<Collision> = projection
.collisions
.iter()
.map(|collision| Collision {
path: collision.destination.clone(),
reason: collision.reason.clone(),
})
.collect();
for candidate in &projection.candidates {
let record = recorded.and_then(|record| record.file(&candidate.destination));
let located = match locate(root, &candidate.destination)? {
Located::Collision(reason) => {
collisions.push(Collision {
path: candidate.destination.clone(),
reason,
});
continue;
}
other => other,
};
let present = matches!(located, Located::Present { .. });
let current = || located.read();
let action = match (candidate.placement, present, record) {
(_, false, _) => Action::Created,
(Placement::Region { .. }, true, _) => Action::Replaced,
(Placement::Whole, true, None) => {
if current()? == candidate.bytes {
Action::Matched
} else {
collisions.push(Collision {
path: candidate.destination.clone(),
reason:
"exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
.to_owned(),
});
continue;
}
}
(Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
(Kind::Rendered, Kind::Rendered) => Action::Replaced,
(Kind::Rendered, Kind::Seeded | Kind::State) => {
collisions.push(Collision {
path: candidate.destination.clone(),
reason: format!(
"is recorded as {}, and this release renders it, so its bytes are the target's",
record.kind.as_str()
),
});
continue;
}
(Kind::Seeded, _) => {
if Digest::of(¤t()?) == record.sha256 {
Action::Preserved
} else {
Action::Drift
}
}
(Kind::State, _) => Action::Preserved,
},
};
decisions.push(Decision {
destination: candidate.destination.clone(),
kind: candidate.kind,
action,
});
}
if let Some(record) = recorded {
for file in &record.files {
let produced = projection
.candidates
.iter()
.any(|candidate| candidate.destination == file.destination);
if !produced {
decisions.push(Decision {
destination: file.destination.clone(),
kind: file.kind,
action: Action::Released,
});
}
}
}
collisions.sort_by(|a, b| a.path.cmp(&b.path));
collisions.dedup_by(|a, b| a.path == b.path);
Ok((decisions, collisions))
}
enum Located {
Collision(String),
Absent,
Present { dir: File, name: std::ffi::OsString },
}
impl Located {
fn read(&self) -> std::io::Result<Vec<u8>> {
match self {
Self::Present { dir, name } => {
held::read_file(dir, name).map(Option::unwrap_or_default)
}
Self::Absent | Self::Collision(_) => Ok(Vec::new()),
}
}
}
fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
let (parent, name) = split(Path::new(destination))?;
let dir = match held::hold_dir_existing(root, parent) {
Ok(dir) => dir,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
Err(error) => {
return Ok(Located::Collision(format!(
"a parent component cannot be held: {error}"
)));
}
};
match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
Located::Collision("exists and is not a regular file".to_owned()),
),
Ok(_) => Ok(Located::Present {
dir,
name: name.to_owned(),
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
Err(error) => Err(error),
}
}
#[must_use]
pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
let listed: Vec<String> = collisions
.iter()
.map(|collision| format!("{} ({})", collision.path, collision.reason))
.collect();
RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"these destinations cannot be landed as they stand, and nothing was written: {}",
listed.join("; ")
),
)
.expected(
"every whole-file destination absent, named by the receipt, or already holding the candidate's bytes, every parent component a directory reached through no link, and every marked document offering its block one place",
)
.action(format!(
"rk stage --target {target} stages this binary's candidate for a byte comparison; the rk-setup skill carries the migration that brings each file to the candidate or records it, then re-run"
))
.target_state("unchanged"),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
Init,
Upgrade,
Adopt,
}
#[derive(Debug)]
pub struct Landed {
pub completed: Vec<String>,
pub config_written: bool,
pub receipt: Manifest,
}
pub fn land(
target: &Held,
recorded: Option<&Manifest>,
prepared: &Prepared,
origin: Origin,
_lock: &lock::TargetLock,
) -> Result<Landed, RkError> {
if !prepared.collisions.is_empty() {
return Err(refusal(target.display(), &prepared.collisions));
}
let root = &target.root;
held::pause(PAUSE_VAR, "validated", "proceed");
let unwritten = reverify(root, prepared, origin)?;
let mut writer = Writer {
root,
completed: Vec::new(),
stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
};
let config_current = read_relative(root, config::CONFIG_PATH)?;
let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
if config_written {
writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
}
let mut files = Vec::new();
for candidate in &prepared.projection.candidates {
let sha256 = match unwritten.get(&candidate.destination) {
Some(digest) => digest.clone(),
None => match action_of(prepared, origin, &candidate.destination) {
Some(Action::Created | Action::Replaced) => {
writer.write(&candidate.destination, &candidate.bytes)?;
candidate_digest(candidate)
}
_ => continue,
},
};
files.push(FileRecord {
destination: candidate.destination.clone(),
kind: candidate.kind,
sha256,
placement: match candidate.placement {
Placement::Whole => manifest::Placement::Whole,
Placement::Region { .. } => manifest::Placement::Region,
},
});
}
let receipt = receipt(&prepared.params, recorded, origin, files);
writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
Ok(Landed {
completed: writer.completed,
config_written,
receipt,
})
}
fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
match origin {
Origin::Adopt => Some(Action::Preserved),
Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
}
}
fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
let Some(current) = read_relative(root, &candidate.destination)? else {
return Ok(None);
};
Ok(match candidate.placement {
Placement::Whole => Some(current),
Placement::Region { begin, end } => {
let text = String::from_utf8_lossy(¤t);
super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
}
})
}
fn reverify(
root: &File,
prepared: &Prepared,
origin: Origin,
) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
let mut digests = std::collections::BTreeMap::new();
for candidate in &prepared.projection.candidates {
let action = action_of(prepared, origin, &candidate.destination);
let must_match = match (origin, action) {
(Origin::Adopt, _) => candidate.kind == Kind::Rendered,
(_, Some(Action::Matched)) => true,
(_, Some(Action::Preserved | Action::Drift)) => false,
_ => continue,
};
let Some(current) = current_form(root, candidate)? else {
return Err(moved(&candidate.destination, "is no longer present"));
};
let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
if must_match && current != expected {
return Err(moved(
&candidate.destination,
"no longer holds the candidate's bytes",
));
}
digests.insert(candidate.destination.clone(), Digest::of(¤t));
}
Ok(digests)
}
fn moved(destination: &str, what: &str) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
),
)
.expected("every destination the landing leaves as it stands to stand still until the receipt is written")
.action("re-run once the target is at rest")
.target_state("unchanged"),
)
}
fn candidate_digest(candidate: &Candidate) -> Digest {
candidate
.region
.as_deref()
.map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
}
fn receipt(
params: &Params,
recorded: Option<&Manifest>,
origin: Origin,
files: Vec<FileRecord>,
) -> Manifest {
Manifest {
schema_version: manifest::SCHEMA_VERSION,
rk_version: env!("CARGO_PKG_VERSION").to_owned(),
origin: recorded.map_or_else(
|| match origin {
Origin::Adopt => "adopt".to_owned(),
Origin::Init | Origin::Upgrade => "init".to_owned(),
},
|record| record.origin.clone(),
),
tech: params.tech().to_owned(),
forge: params.forge().to_owned(),
landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
parameters: Parameters {
repo: params.repo().to_owned(),
workflow: params.workflow(),
style: params.style(),
nix: params.nix(),
trunk: params.trunk().to_owned(),
line_prefix: params.line_prefix().to_owned(),
security_contact: params.security_contact().to_owned(),
security_response: params.security_response().to_owned(),
},
files,
pins: crate::registry::pins_for(params.tech())
.into_iter()
.map(|pin| (pin.name, pin.version))
.collect(),
}
}
fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
let path = Path::new(relative);
let (parent, name) = split(path)?;
let dir = match held::hold_dir_existing(root, parent) {
Ok(dir) => dir,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
held::read_file(&dir, name)
}
fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
let name = path
.file_name()
.ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
let parent = path.parent().unwrap_or_else(|| Path::new(""));
Ok((parent, name))
}
struct Writer<'a> {
root: &'a File,
completed: Vec<String>,
stop: Option<String>,
}
impl Writer<'_> {
fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
let path = Path::new(destination);
let (parent, name) = split(path)?;
let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
if self.stop.as_deref() == Some(destination) {
return Err(std::io::Error::other(
"the rename was stopped here for the proof",
));
}
held::write_file(&dir, name, bytes)
});
match outcome {
Ok(()) => {
self.completed.push(destination.to_owned());
Ok(())
}
Err(error) => Err(self.failure(destination, bytes, &error)),
}
}
fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
let observed = match read_relative(self.root, destination) {
Ok(None) => "is absent".to_owned(),
Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
Err(again) => format!("could not be observed again: {again}"),
};
let completed = if self.completed.is_empty() {
"none".to_owned()
} else {
self.completed.join(", ")
};
RkError::Io(std::io::Error::new(
error.kind(),
format!(
"the landing stopped at {destination}: {error}; observed again, {destination} {observed}; the previous receipt stands; these landed before it: {completed}; re-run to land the rest"
),
))
}
}
#[cfg(test)]
mod tests {
use super::{Action, Held, Origin, Prepared, decide, land, prepare};
use crate::landing::manifest::{self, Manifest};
use crate::landing::{Params, Style, lock};
use crate::projection::{Projection, ProjectionInput, TargetEvidence};
fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
let dir = tempfile::tempdir().expect("a scratch target exists");
let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
(dir, path)
}
fn params() -> Params {
Params::for_test("acme/widget", Some(Style::Trunk))
}
fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
prepare(
&Held::open(target).expect("opens"),
recorded,
¶ms(),
None,
)
.expect("prepares")
}
fn landed(
target: &camino::Utf8Path,
recorded: Option<&Manifest>,
origin: Origin,
) -> super::Landed {
let locks = tempfile::tempdir().expect("a scratch locks directory exists");
let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
let held = Held::open(target).expect("opens");
land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
}
#[test]
fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
let (_dir, target) = target();
let first = prepared(&target, None);
assert!(first.collisions.is_empty(), "{:?}", first.collisions);
assert!(
first
.decisions
.iter()
.all(|decision| decision.action == Action::Created)
);
let outcome = landed(&target, None, Origin::Init);
assert_eq!(
outcome.completed.last().map(String::as_str),
Some(manifest::MANIFEST_PATH)
);
assert_eq!(outcome.receipt.schema_version, 7);
let record = manifest::load(&target).expect("loads").expect("exists");
let again = prepared(&target, Some(&record));
for decision in &again.decisions {
let expected = match decision.kind {
crate::landing::Kind::Rendered => Action::Replaced,
crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
};
assert_eq!(decision.action, expected, "{}", decision.destination);
}
}
#[test]
fn every_collision_is_collected_and_the_refusal_writes_nothing() {
let (_dir, target) = target();
std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
std::fs::write(
target.join("AGENTS.md"),
format!(
"{b}\n{e}\n{b}\n{e}\n",
b = crate::landing::BLOCK_BEGIN,
e = crate::landing::BLOCK_END
),
)
.expect("writes");
let prepared = prepared(&target, None);
let paths: Vec<&str> = prepared
.collisions
.iter()
.map(|collision| collision.path.as_str())
.collect();
assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
let locks = tempfile::tempdir().expect("a scratch locks directory exists");
let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
let held = Held::open(&target).expect("opens");
let refused =
land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
assert_eq!(refused.exit_code(), 73);
assert!(!target.join(".release-kit").exists());
assert!(!target.join("dist-workspace.toml").exists());
}
#[test]
fn a_released_destination_stays_and_leaves_the_receipt() {
let (_dir, target) = target();
landed(&target, None, Origin::Init);
let mut record = manifest::load(&target).expect("loads").expect("exists");
std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
record.files.push(manifest::FileRecord {
destination: "legacy.yml".into(),
kind: crate::landing::Kind::Rendered,
sha256: crate::digest::Digest::of(b"old\n"),
placement: manifest::Placement::Whole,
});
let (decisions, _) = decide(
&Held::open(&target).expect("opens"),
Some(&record),
&Projection::compute(&ProjectionInput {
params: params(),
evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
})
.expect("projects"),
)
.expect("decides");
let released = decisions
.iter()
.find(|decision| decision.destination == "legacy.yml")
.expect("the released destination is decided");
assert_eq!(released.action, Action::Released);
let outcome = landed(&target, Some(&record), Origin::Upgrade);
assert!(target.join("legacy.yml").is_file());
assert!(outcome.receipt.file("legacy.yml").is_none());
}
}