use super::catalog::ContractCatalog;
use super::chain::{check_predecessor_chain, ObservedPredecessor};
use super::diagnostic::{
ContractCheckResult, ContractDiagnostic, ContractDiagnosticCode,
};
use super::ContractArtifactKind;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ContractAcceptScope {
Catalog,
MigrationInventory,
SurfaceClientManifest,
GeneratedClientTree,
ApplicationManifest,
DeploymentPlan,
Program { id: String },
}
impl ContractAcceptScope {
pub fn parse(value: &str) -> Option<Self> {
match value {
"catalog" => Some(Self::Catalog),
"migration_inventory" => Some(Self::MigrationInventory),
"surface_client_manifest" => Some(Self::SurfaceClientManifest),
"generated_client_tree" => Some(Self::GeneratedClientTree),
"application_manifest" => Some(Self::ApplicationManifest),
"deployment_plan" => Some(Self::DeploymentPlan),
other if other.starts_with("program:") => {
let id = other.trim_start_matches("program:").to_string();
if id.is_empty() {
None
} else {
Some(Self::Program { id })
}
}
_ => None,
}
}
pub fn as_str(&self) -> String {
match self {
Self::Catalog => "catalog".into(),
Self::MigrationInventory => "migration_inventory".into(),
Self::SurfaceClientManifest => "surface_client_manifest".into(),
Self::GeneratedClientTree => "generated_client_tree".into(),
Self::ApplicationManifest => "application_manifest".into(),
Self::DeploymentPlan => "deployment_plan".into(),
Self::Program { id } => format!("program:{id}"),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct ContractsCheckReport {
pub ok: bool,
pub result: ContractCheckResult,
pub human: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ContractsAcceptReport {
pub ok: bool,
pub scope: String,
pub changed_paths: Vec<String>,
pub noop: bool,
pub rolled_back: bool,
pub diagnostics: Vec<String>,
}
pub fn contracts_check(
catalog: &ContractCatalog,
root: &Path,
predecessors: impl IntoIterator<Item = ObservedPredecessor>,
) -> ContractsCheckReport {
let mut result = catalog.check(root);
let chain = check_predecessor_chain(predecessors);
result.diagnostics.extend(chain.diagnostics);
let ok = result.diagnostics.is_empty();
let human = result
.diagnostics
.iter()
.map(|diagnostic| diagnostic.human())
.collect::<Vec<_>>()
.join("\n");
ContractsCheckReport { ok, result, human }
}
pub fn contracts_accept(
root: &Path,
scope: ContractAcceptScope,
staged: &BTreeMap<String, Vec<u8>>,
) -> Result<ContractsAcceptReport, String> {
if staged.is_empty() {
return Ok(ContractsAcceptReport {
ok: true,
scope: scope.as_str(),
changed_paths: Vec::new(),
noop: true,
rolled_back: false,
diagnostics: Vec::new(),
});
}
let mut resolved = BTreeMap::new();
for (relative, bytes) in staged {
let path = resolve_under_root(root, relative)?;
resolved.insert(relative.clone(), (path, bytes.clone()));
}
let mut noop = true;
for (path, bytes) in resolved.values() {
match fs::read(path) {
Ok(existing) if existing == *bytes => {}
_ => {
noop = false;
break;
}
}
}
if noop {
return Ok(ContractsAcceptReport {
ok: true,
scope: scope.as_str(),
changed_paths: Vec::new(),
noop: true,
rolled_back: false,
diagnostics: Vec::new(),
});
}
let mut prior = BTreeMap::new();
for (relative, (path, _)) in &resolved {
prior.insert(
relative.clone(),
if path.exists() {
Some(fs::read(path).map_err(|error| error.to_string())?)
} else {
None
},
);
}
let mut changed = Vec::new();
for (relative, (path, bytes)) in &resolved {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
let staging = path.with_extension("distributed-accept-staging");
if let Err(error) = fs::write(&staging, bytes) {
let _ = rollback(root, &prior);
return Err(error.to_string());
}
if let Err(error) = fs::rename(&staging, path) {
let _ = fs::remove_file(&staging);
let _ = rollback(root, &prior);
return Err(error.to_string());
}
changed.push(relative.clone());
}
Ok(ContractsAcceptReport {
ok: true,
scope: scope.as_str(),
changed_paths: changed,
noop: false,
rolled_back: false,
diagnostics: Vec::new(),
})
}
fn rollback(
root: &Path,
prior: &BTreeMap<String, Option<Vec<u8>>>,
) -> Result<(), String> {
for (relative, contents) in prior {
let path = resolve_under_root(root, relative)?;
match contents {
Some(bytes) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&path, bytes).map_err(|e| e.to_string())?;
}
None if path.exists() => {
fs::remove_file(&path).map_err(|e| e.to_string())?;
}
None => {}
}
}
Ok(())
}
fn resolve_under_root(root: &Path, relative: &str) -> Result<PathBuf, String> {
if relative.is_empty()
|| relative.starts_with('/')
|| relative.contains('\0')
|| Path::new(relative)
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(format!(
"accept path `{relative}` escapes or is not a portable relative path"
));
}
let root = root
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf());
let candidate = root.join(relative);
if let Ok(canonical) = candidate.canonicalize() {
if !canonical.starts_with(&root) {
return Err(format!(
"accept path `{relative}` resolves outside the catalog root"
));
}
return Ok(canonical);
}
if let Some(parent) = candidate.parent() {
if parent.exists() {
let parent = parent.canonicalize().map_err(|e| e.to_string())?;
if !parent.starts_with(&root) {
return Err(format!(
"accept path `{relative}` parent resolves outside the catalog root"
));
}
}
}
Ok(candidate)
}
pub fn unknown_scope_diagnostic(scope: &str) -> ContractDiagnostic {
ContractDiagnostic::new(
ContractDiagnosticCode::CatalogInvalid,
Some(ContractArtifactKind::ApplicationManifest),
None::<&str>,
"contracts.accept",
std::iter::empty::<&str>(),
std::iter::empty::<&str>(),
Some("scope"),
Some("exact catalog-owned scope"),
Some(scope),
Some("use_exact_scope"),
None,
"distributed contracts accept --scope <exact-scope>",
)
.with_detail(format!("unknown or broad accept scope `{scope}`"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_root() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("distributed-contracts-accept-{nanos}"));
fs::create_dir_all(&root).unwrap();
root
}
#[test]
fn accept_is_idempotent_and_rolls_back_on_escape() {
let root = temp_root();
let mut staged = BTreeMap::new();
staged.insert("contracts/app.json".into(), b"{\"ok\":true}".to_vec());
let first = contracts_accept(&root, ContractAcceptScope::ApplicationManifest, &staged)
.expect("first accept");
assert!(first.ok);
assert!(!first.noop);
assert_eq!(first.changed_paths, vec!["contracts/app.json".to_string()]);
let second = contracts_accept(&root, ContractAcceptScope::ApplicationManifest, &staged)
.expect("second accept");
assert!(second.noop);
let mut bad = BTreeMap::new();
bad.insert("../escape.json".into(), b"nope".to_vec());
assert!(contracts_accept(&root, ContractAcceptScope::Catalog, &bad).is_err());
let _ = fs::remove_dir_all(root);
}
#[test]
fn check_report_is_no_write() {
let report = ContractsCheckReport {
ok: true,
result: ContractCheckResult::default(),
human: String::new(),
};
assert!(report.ok);
}
}