use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::apply::run_store::RunId;
use super::component_problem;
use super::node::{self, Node};
use super::projects::Release;
use super::tag_record::{node_path_problem, order_problem};
use crate::error::CoreError;
pub const PROJECT_JOURNAL_FILE: &str = "journal.json";
pub const PROJECT_JOURNAL_MAX_BYTES: usize = 65_536;
pub const PRE_IMAGE_DIR: &str = "pre-image";
pub const PRE_IMAGE_MAX_BYTES: usize = 512 * 1024 * 1024;
pub const PRE_IMAGE_RUN_PROVIDER_BYTES: usize = 5 * crate::deploy::tag_record::TAG_RECORD_MAX_BYTES;
pub fn pre_image_run_max_bytes(providers: usize) -> usize {
PRE_IMAGE_RUN_PROVIDER_BYTES
.saturating_mul(providers.max(1))
.saturating_add(1024 * 1024)
}
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum PreImageFile {
Run,
Project,
Release,
Tags(String),
Provider(String),
Record(String),
Sent(String),
}
impl PreImageFile {
pub fn file_name(&self) -> String {
match self {
PreImageFile::Run => "run.json".to_string(),
PreImageFile::Project => "project.zip".to_string(),
PreImageFile::Release => "release-record.json".to_string(),
PreImageFile::Tags(p) => format!("tags-{p}.json"),
PreImageFile::Provider(p) => format!("provider-{p}.json"),
PreImageFile::Record(p) => format!("record-{p}.json"),
PreImageFile::Sent(p) => format!("sent-{p}.json"),
}
}
pub fn parse(name: &str) -> Option<PreImageFile> {
match name {
"run.json" => return Some(PreImageFile::Run),
"project.zip" => return Some(PreImageFile::Project),
"release-record.json" => return Some(PreImageFile::Release),
_ => {}
}
let stem = name.strip_suffix(".json")?;
let (kind, provider) = if let Some(p) = stem.strip_prefix("tags-") {
(0, p)
} else if let Some(p) = stem.strip_prefix("provider-") {
(1, p)
} else if let Some(p) = stem.strip_prefix("record-") {
(2, p)
} else {
(3, stem.strip_prefix("sent-")?)
};
if component_problem(provider).is_some() || provider.chars().any(char::is_control) {
return None;
}
let provider = provider.to_string();
Some(match kind {
0 => PreImageFile::Tags(provider),
1 => PreImageFile::Provider(provider),
2 => PreImageFile::Record(provider),
_ => PreImageFile::Sent(provider),
})
}
pub fn provider(&self) -> Option<&str> {
match self {
PreImageFile::Tags(p)
| PreImageFile::Provider(p)
| PreImageFile::Record(p)
| PreImageFile::Sent(p) => Some(p),
PreImageFile::Run | PreImageFile::Project | PreImageFile::Release => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JournalState {
Applying,
RollingBack,
}
impl JournalState {
pub fn as_str(self) -> &'static str {
match self {
JournalState::Applying => "applying",
JournalState::RollingBack => "rolling_back",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectJournal {
pub schema_version: u32,
pub gateway: String,
pub project: String,
pub run_id: String,
pub state: JournalState,
pub started_at: String,
pub providers: Vec<String>,
}
const JOURNAL_FIELDS: [&str; 7] = [
"schema_version",
"gateway",
"project",
"run_id",
"state",
"started_at",
"providers",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreatedProof {
pub confirmed: bool,
pub signature: Option<String>,
pub enabled: bool,
pub description: Option<String>,
pub config: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PreImageProvider {
pub name: String,
pub settings: String,
pub added: Vec<String>,
pub changed: Vec<String>,
pub created: Option<CreatedProof>,
#[serde(default)]
pub removed: Vec<String>,
#[serde(default)]
pub deleted: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sent: Option<Vec<String>>,
#[serde(default)]
pub collided: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PreImageRun {
pub schema_version: u32,
pub gateway: String,
pub project: String,
pub run_id: String,
pub release: String,
pub written_at: String,
pub project_action: String,
pub groups: bool,
pub providers: Vec<PreImageProvider>,
pub files: BTreeMap<String, String>,
}
const RUN_FIELDS: [&str; 10] = [
"schema_version",
"gateway",
"project",
"run_id",
"release",
"written_at",
"project_action",
"groups",
"providers",
"files",
];
fn shown(text: &str) -> String {
text.chars().filter(|c| !c.is_control()).take(64).collect()
}
fn lower_hex64(text: &str) -> bool {
text.len() == 64
&& text
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn stamp_ok(text: &str) -> bool {
!text.is_empty() && text.len() <= 64 && text.bytes().all(|b| b.is_ascii_graphic())
}
fn strict_object(
bytes: &[u8],
cap: usize,
fields: &[&str],
bad: &dyn Fn(String) -> CoreError,
) -> Result<(), CoreError> {
if bytes.is_empty() {
return Err(bad("the file is empty".to_string()));
}
if bytes.len() > cap {
return Err(bad(format!(
"the file is {} bytes, over the {cap} byte cap",
bytes.len()
)));
}
let text = std::str::from_utf8(bytes).map_err(|e| {
bad(format!(
"not valid UTF-8 (valid up to byte {})",
e.valid_up_to()
))
})?;
if text.starts_with('\u{feff}') {
return Err(bad("starts with a byte-order mark".to_string()));
}
let root = node::parse(text).map_err(bad)?;
let Node::Obj(members) = &root else {
return Err(bad("the top level is not a JSON object".to_string()));
};
for (key, _) in members {
if !fields.contains(&key.as_str()) {
return Err(bad(format!("unknown field `{}`", shown(key))));
}
}
for field in fields {
if root.get(field).is_none() {
return Err(bad(format!("missing field `{field}`")));
}
}
Ok(())
}
fn common_checks(
schema_version: u32,
(gateway, project, run_id): (&str, &str, &str),
(want_gateway, want_project): (&str, &str),
bad: &dyn Fn(String) -> CoreError,
) -> Result<(), CoreError> {
if schema_version != SCHEMA_VERSION {
return Err(bad(format!(
"schema_version is not {SCHEMA_VERSION}, the only version this build reads"
)));
}
if gateway != want_gateway {
return Err(bad(format!(
"gateway names a different gateway than the target (`{}`)",
shown(want_gateway)
)));
}
if project != want_project {
return Err(bad(format!(
"project names a different project than this path (`{}`)",
shown(want_project)
)));
}
if RunId::parse(run_id).is_err() {
return Err(bad(
"run_id is not 1 to 128 characters of [A-Za-z0-9._-]".to_string()
));
}
Ok(())
}
fn provider_names_ok(
names: &[&str],
what: &str,
bad: &dyn Fn(String) -> CoreError,
) -> Result<(), CoreError> {
for (i, name) in names.iter().enumerate() {
if component_problem(name).is_some() || name.chars().any(char::is_control) {
return Err(bad(format!("{what}[{i}] is not one safe provider name")));
}
}
if names
.windows(2)
.any(|pair| pair[0].as_bytes() >= pair[1].as_bytes())
{
return Err(bad(format!(
"{what} is out of byte order or repeats an entry"
)));
}
Ok(())
}
fn location_error(location: &str) -> impl Fn(String) -> CoreError + '_ {
move |problem| CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
}
}
impl ProjectJournal {
pub fn parse(
bytes: &[u8],
location: &str,
gateway: &str,
project: &str,
) -> Result<ProjectJournal, CoreError> {
let bad = location_error(location);
strict_object(bytes, PROJECT_JOURNAL_MAX_BYTES, &JOURNAL_FIELDS, &bad)?;
let journal: ProjectJournal = serde_json::from_slice(bytes).map_err(|e| {
bad(format!(
"a field has the wrong type (line {} column {})",
e.line(),
e.column()
))
})?;
common_checks(
journal.schema_version,
(&journal.gateway, &journal.project, &journal.run_id),
(gateway, project),
&bad,
)?;
if !stamp_ok(&journal.started_at) {
return Err(bad(
"started_at is not 1 to 64 printable ASCII characters".to_string()
));
}
let names: Vec<&str> = journal.providers.iter().map(String::as_str).collect();
provider_names_ok(&names, "providers", &bad)?;
Ok(journal)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
bytes.push(b'\n');
bytes
}
}
impl PreImageRun {
pub fn parse(
bytes: &[u8],
location: &str,
gateway: &str,
project: &str,
) -> Result<PreImageRun, CoreError> {
let bad = location_error(location);
strict_object(bytes, PRE_IMAGE_MAX_BYTES, &RUN_FIELDS, &bad)?;
let run: PreImageRun = serde_json::from_slice(bytes).map_err(|e| {
bad(format!(
"a field has the wrong type (line {} column {})",
e.line(),
e.column()
))
})?;
let cap = pre_image_run_max_bytes(run.providers.len());
if bytes.len() > cap {
return Err(bad(format!(
"the file is {} bytes, over the {cap} byte cap for {} provider(s)",
bytes.len(),
run.providers.len()
)));
}
common_checks(
run.schema_version,
(&run.gateway, &run.project, &run.run_id),
(gateway, project),
&bad,
)?;
if Release::try_parse(&run.release).is_none() {
return Err(bad("release is not a semver tag".to_string()));
}
if !stamp_ok(&run.written_at) {
return Err(bad(
"written_at is not 1 to 64 printable ASCII characters".to_string()
));
}
if !matches!(
run.project_action.as_str(),
"create" | "import" | "record" | "skip"
) {
return Err(bad(
"project_action is not create, import, record or skip".to_string()
));
}
let names: Vec<&str> = run.providers.iter().map(|p| p.name.as_str()).collect();
provider_names_ok(&names, "providers", &bad)?;
for (i, provider) in run.providers.iter().enumerate() {
if !matches!(provider.settings.as_str(), "same" | "modify" | "create") {
return Err(bad(format!(
"providers[{i}].settings is not same, modify or create"
)));
}
let no_sent = Vec::new();
let sent = provider.sent.as_ref().unwrap_or(&no_sent);
for (list, what) in [
(&provider.added, "added"),
(&provider.changed, "changed"),
(&provider.removed, "removed"),
(&provider.deleted, "deleted"),
(sent, "sent"),
(&provider.collided, "collided"),
] {
for (j, path) in list.iter().enumerate() {
if let Some(why) = node_path_problem(path) {
return Err(bad(format!(
"providers[{i}].{what}[{j}] is not a provider-relative node path: it {why}"
)));
}
}
if let Some(j) = order_problem(list) {
return Err(bad(format!(
"providers[{i}].{what}[{j}] is out of byte order or repeats an entry"
)));
}
}
if let Some((j, path)) = provider
.deleted
.iter()
.enumerate()
.find(|(_, path)| provider.removed.binary_search(path).is_err())
{
return Err(bad(format!(
"providers[{i}].deleted[{j}] `{}` is not one of the run's removals: the set \
is stale or partial",
shown(path)
)));
}
if let Some((j, path)) = sent.iter().enumerate().find(|(_, path)| {
provider.added.binary_search(path).is_err()
&& provider.changed.binary_search(path).is_err()
}) {
return Err(bad(format!(
"providers[{i}].sent[{j}] `{}` is not one of the run's planned nodes: the set \
is stale or partial",
shown(path)
)));
}
if let Some((j, path)) = provider.collided.iter().enumerate().find(|(_, path)| {
sent.binary_search(path).is_err() || provider.added.binary_search(path).is_err()
}) {
return Err(bad(format!(
"providers[{i}].collided[{j}] `{}` is not an added node the run sent: the set \
is stale or partial",
shown(path)
)));
}
if let Some(created) = &provider.created
&& created
.signature
.as_deref()
.is_some_and(|s| s.is_empty() || s.chars().any(char::is_control))
{
return Err(bad(format!(
"providers[{i}].created.signature is empty or holds a control character"
)));
}
}
for (name, sha) in &run.files {
let Some(file) = PreImageFile::parse(name) else {
return Err(bad(format!(
"files names `{}`, which is not a pre-image file name",
shown(name)
)));
};
if file == PreImageFile::Run {
return Err(bad("files names run.json itself".to_string()));
}
if let Some(provider) = file.provider()
&& !names.contains(&provider)
{
return Err(bad(format!(
"files names `{}` for a provider the set does not list",
shown(name)
)));
}
if !lower_hex64(sha) {
return Err(bad(format!(
"files.`{}` is not 64 lowercase hex digits",
shown(name)
)));
}
}
Ok(run)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
bytes.push(b'\n');
bytes
}
pub fn provider(&self, name: &str) -> Option<&PreImageProvider> {
self.providers.iter().find(|p| p.name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
const LOC: &str = "ign-deploy/gw/projects/CapApp/journal.json";
fn journal() -> ProjectJournal {
ProjectJournal {
schema_version: 1,
gateway: "gw".to_string(),
project: "CapApp".to_string(),
run_id: "20261109T101500Z-ab12".to_string(),
state: JournalState::Applying,
started_at: "2026-11-09T10:15:00Z".to_string(),
providers: vec!["PlantA".to_string()],
}
}
fn run() -> PreImageRun {
PreImageRun {
schema_version: 1,
gateway: "gw".to_string(),
project: "CapApp".to_string(),
run_id: "20261109T101500Z-ab12".to_string(),
release: "v1.0.0".to_string(),
written_at: "2026-11-09T10:15:00Z".to_string(),
project_action: "import".to_string(),
groups: false,
providers: vec![PreImageProvider {
name: "PlantA".to_string(),
settings: "same".to_string(),
added: vec!["Line1/Speed".to_string()],
changed: Vec::new(),
created: None,
removed: Vec::new(),
deleted: Vec::new(),
sent: None,
collided: Vec::new(),
}],
files: BTreeMap::from([
("project.zip".to_string(), "a".repeat(64)),
("tags-PlantA.json".to_string(), "b".repeat(64)),
]),
}
}
#[test]
fn pre_image_names_are_the_fixed_set() {
for (name, file) in [
("run.json", PreImageFile::Run),
("project.zip", PreImageFile::Project),
("release-record.json", PreImageFile::Release),
("tags-Plant A.json", PreImageFile::Tags("Plant A".into())),
("provider-P.json", PreImageFile::Provider("P".into())),
("record-P.json", PreImageFile::Record("P".into())),
] {
assert_eq!(PreImageFile::parse(name), Some(file.clone()), "{name}");
assert_eq!(file.file_name(), name);
}
for bad in [
"journal.json",
"release.json",
"tags-.json",
"tags-...json",
"tags-a/b.json",
"other-P.json",
"tags-P.zip",
"run.json.tmp",
] {
assert_eq!(PreImageFile::parse(bad), None, "{bad}");
}
assert_eq!(
PRE_IMAGE_MAX_BYTES,
crate::actions::projects::IMPORT_MAX_BYTES
);
}
#[test]
fn journal_round_trips_and_is_strict() {
let j = journal();
assert_eq!(
ProjectJournal::parse(&j.to_bytes(), LOC, "gw", "CapApp").expect("parses"),
j
);
let mut rolling = j.clone();
rolling.state = JournalState::RollingBack;
assert!(
String::from_utf8(rolling.to_bytes())
.unwrap()
.contains("rolling_back")
);
for (bytes, gw, project) in [
(j.to_bytes(), "other", "CapApp"),
(j.to_bytes(), "gw", "Other"),
(b"{}".to_vec(), "gw", "CapApp"),
(b"not json".to_vec(), "gw", "CapApp"),
(vec![b' '; PROJECT_JOURNAL_MAX_BYTES + 1], "gw", "CapApp"),
(
br#"{"schema_version":1,"schema_version":1}"#.to_vec(),
"gw",
"CapApp",
),
] {
let err = ProjectJournal::parse(&bytes, LOC, gw, project).expect_err("refused");
assert_eq!(err.code(), "deploy_run_invalid", "{err}");
assert!(err.to_string().contains(LOC), "{err}");
}
let mut extra: Value = serde_json::from_slice(&j.to_bytes()).unwrap();
extra["secret"] = Value::from("x");
let err = ProjectJournal::parse(&serde_json::to_vec(&extra).unwrap(), LOC, "gw", "CapApp")
.expect_err("unknown field");
assert!(!err.to_string().contains("\"x\""), "{err}");
let mut bad_run = j.clone();
bad_run.run_id = "../x".to_string();
assert!(ProjectJournal::parse(&bad_run.to_bytes(), LOC, "gw", "CapApp").is_err());
}
#[test]
fn a_run_json_over_one_record_cap_still_parses() {
let mut r = run();
let paths: Vec<String> = (0..300_000).map(|i| format!("Line1/Tag{i:07}")).collect();
r.providers[0].added = paths.clone();
r.providers[0].sent = Some(paths);
let bytes = r.to_bytes();
assert!(
bytes.len() > crate::deploy::tag_record::TAG_RECORD_MAX_BYTES,
"{}",
bytes.len()
);
PreImageRun::parse(&bytes, LOC, "gw", "CapApp").expect("parses");
assert_eq!(
pre_image_run_max_bytes(3) - pre_image_run_max_bytes(2),
PRE_IMAGE_RUN_PROVIDER_BYTES
);
assert_eq!(pre_image_run_max_bytes(0), pre_image_run_max_bytes(1));
}
#[test]
fn run_round_trips_and_is_strict() {
let r = run();
assert_eq!(
PreImageRun::parse(&r.to_bytes(), LOC, "gw", "CapApp").expect("parses"),
r
);
let mut stray = r.clone();
stray
.files
.insert("tags-Other.json".to_string(), "c".repeat(64));
assert!(PreImageRun::parse(&stray.to_bytes(), LOC, "gw", "CapApp").is_err());
let mut odd = r.clone();
odd.files.insert("journal.json".to_string(), "c".repeat(64));
assert!(PreImageRun::parse(&odd.to_bytes(), LOC, "gw", "CapApp").is_err());
let mut unsorted = r.clone();
unsorted.providers[0].added = vec!["b".into(), "a".into()];
assert!(PreImageRun::parse(&unsorted.to_bytes(), LOC, "gw", "CapApp").is_err());
let mut action = r;
action.project_action = "delete".to_string();
assert!(PreImageRun::parse(&action.to_bytes(), LOC, "gw", "CapApp").is_err());
}
#[test]
fn pre_image_run_sent_paths_are_planned() {
let mut r = run();
r.providers[0].sent = Some(vec!["Line1/Speed".into()]);
r.providers[0].collided = vec!["Line1/Speed".into()];
r.files
.insert("sent-PlantA.json".to_string(), "c".repeat(64));
let parsed = PreImageRun::parse(&r.to_bytes(), LOC, "gw", "CapApp").expect("valid");
assert_eq!(parsed, r);
let mut stray = r.clone();
stray.providers[0].sent = Some(vec!["Line1/Other".into()]);
stray.providers[0].collided = Vec::new();
let err = PreImageRun::parse(&stray.to_bytes(), LOC, "gw", "CapApp").expect_err("stray");
assert!(err.to_string().contains("sent[0]"), "{err}");
let mut unsent = r.clone();
unsent.providers[0].sent = Some(Vec::new());
let err = PreImageRun::parse(&unsent.to_bytes(), LOC, "gw", "CapApp").expect_err("unsent");
assert!(err.to_string().contains("collided[0]"), "{err}");
let mut old: Value = serde_json::from_slice(&run().to_bytes()).unwrap();
old["providers"][0]
.as_object_mut()
.unwrap()
.remove("collided");
let parsed = PreImageRun::parse(&serde_json::to_vec(&old).unwrap(), LOC, "gw", "CapApp")
.expect("an earlier set");
assert_eq!(parsed.providers[0].sent, None);
assert!(parsed.providers[0].collided.is_empty());
assert_eq!(
PreImageFile::parse("sent-PlantA.json"),
Some(PreImageFile::Sent("PlantA".to_string()))
);
assert_eq!(
PreImageFile::Sent("P".to_string()).file_name(),
"sent-P.json"
);
}
#[test]
fn pre_image_run_deleted_paths_are_removals() {
let mut r = run();
r.providers[0].removed = vec!["Line1/A".into(), "Line1/B".into()];
r.providers[0].deleted = vec!["Line1/A".into()];
let parsed = PreImageRun::parse(&r.to_bytes(), LOC, "gw", "CapApp").expect("valid");
assert_eq!(parsed.providers[0].deleted, ["Line1/A"]);
let mut stray = r.clone();
stray.providers[0].deleted = vec!["Line1/C".into()];
let err = PreImageRun::parse(&stray.to_bytes(), LOC, "gw", "CapApp").expect_err("stray");
assert_eq!(err.code(), "deploy_run_invalid");
assert!(err.to_string().contains("Line1/C"), "{err}");
let mut unsorted = r.clone();
unsorted.providers[0].removed = vec!["Line1/B".into(), "Line1/A".into()];
assert!(PreImageRun::parse(&unsorted.to_bytes(), LOC, "gw", "CapApp").is_err());
let mut old: serde_json::Value = serde_json::from_slice(&run().to_bytes()).expect("json");
for provider in old["providers"].as_array_mut().expect("providers") {
let object = provider.as_object_mut().expect("object");
object.remove("removed");
object.remove("deleted");
}
let parsed = PreImageRun::parse(
&serde_json::to_vec(&old).expect("json"),
LOC,
"gw",
"CapApp",
)
.expect("an earlier set");
assert!(parsed.providers[0].removed.is_empty() && parsed.providers[0].deleted.is_empty());
}
}