1use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38
39use super::journal::{
40 canonical_bytes, check_header, check_touched, is_hex64, parse_strict, refuse,
41};
42use super::run_store::RunId;
43use crate::deploy::node::Node;
44use crate::error::CoreError;
45
46pub const RECORD_FILE: &str = "record.json";
48
49pub const RECORD_MAX_BYTES: usize = 8_388_608;
51
52const FIELDS: [&str; 11] = [
54 "schema_version",
55 "run_id",
56 "gateway",
57 "mode",
58 "render_sha256",
59 "plan_sha256",
60 "state",
61 "rolled_back_by",
62 "touched",
63 "pre_fingerprint",
64 "post_fingerprint",
65];
66
67const ENTRY_FIELDS: [&str; 3] = ["path", "pre_sha256", "post_sha256"];
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum RecordState {
74 Applying,
76 Applied,
78 RolledBack,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct TouchedEntry {
86 pub path: String,
88 pub pre_sha256: Option<String>,
90 pub post_sha256: Option<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct RunRecord {
98 pub schema_version: u32,
100 pub run_id: String,
102 pub gateway: String,
104 pub mode: String,
106 pub render_sha256: String,
108 pub plan_sha256: String,
110 pub state: RecordState,
112 pub rolled_back_by: Option<String>,
114 pub touched: Vec<TouchedEntry>,
116 pub pre_fingerprint: String,
118 pub post_fingerprint: Option<String>,
120}
121
122impl RunRecord {
123 pub fn parse(
126 location: &str,
127 bytes: &[u8],
128 gateway: &str,
129 mode: &str,
130 ) -> Result<RunRecord, CoreError> {
131 let bad = |problem: String| refuse(location, bytes, problem);
132 let (record, root): (RunRecord, Node) = parse_strict(
133 location,
134 bytes,
135 RECORD_MAX_BYTES,
136 &FIELDS,
137 &[("state", &["applying", "applied", "rolled_back"])],
138 )?;
139 if let Some(Node::Arr(entries)) = root.get("touched") {
142 for (index, entry) in entries.iter().enumerate() {
143 let Node::Obj(members) = entry else {
144 return Err(bad(format!("touched[{index}] is not a JSON object")));
145 };
146 if let Some((key, _)) = members
147 .iter()
148 .find(|(k, _)| !ENTRY_FIELDS.contains(&k.as_str()))
149 {
150 return Err(bad(format!(
151 "touched[{index}] has the unknown field `{}`",
152 super::journal::shown(key)
153 )));
154 }
155 if let Some(missing) = ENTRY_FIELDS.iter().find(|f| entry.get(f).is_none()) {
156 return Err(bad(format!("touched[{index}] is missing `{missing}`")));
157 }
158 }
159 }
160 check_header(
161 record.schema_version,
162 &record.run_id,
163 (&record.gateway, gateway),
164 (&record.mode, mode),
165 )
166 .map_err(bad)?;
167 for (name, value) in [
168 ("render_sha256", Some(&record.render_sha256)),
169 ("plan_sha256", Some(&record.plan_sha256)),
170 ("pre_fingerprint", Some(&record.pre_fingerprint)),
171 ("post_fingerprint", record.post_fingerprint.as_ref()),
172 ] {
173 if value.is_some_and(|v| !is_hex64(v)) {
174 return Err(bad(format!("{name} is not 64 lowercase hex digits")));
175 }
176 }
177 if record.state == RecordState::Applying && record.post_fingerprint.is_some() {
178 return Err(bad(
179 "post_fingerprint is set while state is applying".to_string()
180 ));
181 }
182 if let Some(by) = &record.rolled_back_by {
183 if record.state != RecordState::RolledBack {
184 return Err(bad(
185 "rolled_back_by is set while state is not rolled_back".to_string()
186 ));
187 }
188 if RunId::parse(by).is_err() {
189 return Err(bad("rolled_back_by is not a valid run id".to_string()));
190 }
191 }
192 check_touched(record.touched.iter().map(|e| e.path.as_str())).map_err(bad)?;
193 for (index, entry) in record.touched.iter().enumerate() {
194 for (name, value) in [
195 ("pre_sha256", &entry.pre_sha256),
196 ("post_sha256", &entry.post_sha256),
197 ] {
198 if value.as_deref().is_some_and(|v| !is_hex64(v)) {
199 return Err(bad(format!(
200 "touched[{index}].{name} is not 64 lowercase hex digits"
201 )));
202 }
203 }
204 }
205 Ok(record)
206 }
207
208 pub fn to_bytes(&self) -> Vec<u8> {
210 canonical_bytes(self)
211 }
212
213 pub fn pre_map(&self) -> BTreeMap<String, Option<String>> {
215 self.touched
216 .iter()
217 .map(|e| (e.path.clone(), e.pre_sha256.clone()))
218 .collect()
219 }
220
221 pub fn post_map(&self) -> BTreeMap<String, Option<String>> {
223 self.touched
224 .iter()
225 .map(|e| (e.path.clone(), e.post_sha256.clone()))
226 .collect()
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::deploy::apply::fingerprint::{LEDGER_KEY, fingerprint};
234 use serde_json::{Value, json};
235
236 const GW: &str = "tracer-gw";
237 const MODE: &str = "dev";
238 const LOC: &str = "~/ign-deploy/tracer-gw/dev/snapshots/20261015T142233Z-a1b2c3/record.json";
239 const H1: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
240 const H2: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
241
242 fn entry(path: &str, pre: Option<&str>, post: Option<&str>) -> TouchedEntry {
243 TouchedEntry {
244 path: path.to_string(),
245 pre_sha256: pre.map(str::to_string),
246 post_sha256: post.map(str::to_string),
247 }
248 }
249
250 fn good() -> RunRecord {
251 RunRecord {
252 schema_version: 1,
253 run_id: "20261015T142233Z-a1b2c3".to_string(),
254 gateway: GW.to_string(),
255 mode: MODE.to_string(),
256 render_sha256: H1.to_string(),
257 plan_sha256: H2.to_string(),
258 state: RecordState::Applied,
259 rolled_back_by: None,
260 touched: vec![
261 entry("ledger.json", None, Some(H1)),
262 entry("mod/type/a.json", Some(H1), Some(H2)),
263 entry("mod/type/b.json", Some(H2), None),
264 ],
265 pre_fingerprint: H1.to_string(),
266 post_fingerprint: Some(H2.to_string()),
267 }
268 }
269
270 fn with(edit: impl FnOnce(&mut Value)) -> Vec<u8> {
271 let mut value: Value = serde_json::from_slice(&good().to_bytes()).expect("good is JSON");
272 edit(&mut value);
273 serde_json::to_vec(&value).expect("serialize")
274 }
275
276 fn refusal(bytes: &[u8]) -> String {
277 let err = RunRecord::parse(LOC, bytes, GW, MODE).expect_err("must be refused");
278 assert_eq!(err.code(), "deploy_run_invalid", "{err}");
279 let text = err.to_string();
280 assert!(text.contains(LOC), "the refusal names the location: {text}");
281 text
282 }
283
284 #[test]
285 fn record_roundtrip() {
286 let record = good();
287 let bytes = record.to_bytes();
288 assert!(bytes.ends_with(b"}\n"));
289 let parsed = RunRecord::parse(LOC, &bytes, GW, MODE).expect("parses");
290 assert_eq!(parsed, record);
291 assert_eq!(parsed.to_bytes(), bytes, "byte-identical");
292
293 let applying = RunRecord {
294 state: RecordState::Applying,
295 post_fingerprint: None,
296 ..good()
297 };
298 assert_eq!(
299 RunRecord::parse(LOC, &applying.to_bytes(), GW, MODE).expect("parses"),
300 applying
301 );
302 let rolled = RunRecord {
303 state: RecordState::RolledBack,
304 rolled_back_by: Some("20261016T090000Z-ffffff".to_string()),
305 ..good()
306 };
307 assert_eq!(
308 RunRecord::parse(LOC, &rolled.to_bytes(), GW, MODE).expect("parses"),
309 rolled
310 );
311 }
312
313 #[test]
314 fn the_layout_is_pinned() {
315 let applying = RunRecord {
316 state: RecordState::Applying,
317 post_fingerprint: None,
318 touched: vec![entry("mod/type/a.json", None, Some(H2))],
319 ..good()
320 };
321 let text = String::from_utf8(applying.to_bytes()).expect("utf-8");
322 let expected = format!(
323 "{{\n \"schema_version\": 1,\n \"run_id\": \"20261015T142233Z-a1b2c3\",\n \"gateway\": \"tracer-gw\",\n \"mode\": \"dev\",\n \"render_sha256\": \"{H1}\",\n \"plan_sha256\": \"{H2}\",\n \"state\": \"applying\",\n \"rolled_back_by\": null,\n \"touched\": [\n {{\n \"path\": \"mod/type/a.json\",\n \"pre_sha256\": null,\n \"post_sha256\": \"{H2}\"\n }}\n ],\n \"pre_fingerprint\": \"{H1}\",\n \"post_fingerprint\": null\n}}\n"
324 );
325 assert_eq!(text, expected);
326 }
327
328 #[test]
329 fn the_maps_feed_the_fingerprint() {
330 let record = good();
331 let pre = record.pre_map();
332 let post = record.post_map();
333 assert_eq!(pre.len(), 3);
334 assert_eq!(pre.get(LEDGER_KEY), Some(&None));
335 assert_eq!(pre.get("mod/type/a.json"), Some(&Some(H1.to_string())));
336 assert_eq!(post.get("mod/type/b.json"), Some(&None));
337 assert_eq!(post.get(LEDGER_KEY), Some(&Some(H1.to_string())));
338 assert_ne!(fingerprint(&pre), fingerprint(&post));
339 }
340
341 #[test]
342 fn refuses_an_empty_file() {
343 assert!(refusal(b"").contains("empty"));
344 }
345
346 #[test]
347 fn refuses_a_file_over_the_cap() {
348 assert!(refusal(&vec![b' '; RECORD_MAX_BYTES + 1]).contains("cap"));
349 }
350
351 #[test]
352 fn refuses_invalid_utf8() {
353 refusal(b"{\"schema_version\": 1, \"run_id\": \"\xff\"}");
354 }
355
356 #[test]
357 fn refuses_a_duplicate_key() {
358 let text = String::from_utf8(good().to_bytes()).expect("utf-8");
359 let dup = text.replacen(
360 "\"mode\": \"dev\"",
361 "\"mode\": \"dev\",\n \"mode\": \"dev\"",
362 1,
363 );
364 refusal(dup.as_bytes());
365 }
366
367 #[test]
368 fn refuses_an_unknown_field_at_either_level() {
369 let top = refusal(&with(|v| v["extra"] = json!(1)));
370 assert!(top.contains("extra"), "{top}");
371 refusal(&with(|v| v["touched"][0]["extra"] = json!(1)));
372 }
373
374 #[test]
375 fn refuses_a_missing_field_at_either_level() {
376 let top = refusal(&with(|v| {
377 v.as_object_mut().expect("object").remove("plan_sha256");
378 }));
379 assert!(top.contains("plan_sha256"), "{top}");
380 refusal(&with(|v| {
381 v["touched"][1]
382 .as_object_mut()
383 .expect("object")
384 .remove("post_sha256");
385 }));
386 refusal(&with(|v| {
387 v.as_object_mut()
388 .expect("object")
389 .remove("post_fingerprint");
390 }));
391 }
392
393 #[test]
394 fn refuses_another_schema_version_gateway_or_mode() {
395 refusal(&with(|v| v["schema_version"] = json!(2)));
396 refusal(&with(|v| v["gateway"] = json!("other-gw")));
397 refusal(&with(|v| v["mode"] = json!("prod")));
398 }
399
400 #[test]
401 fn refuses_a_bad_run_id() {
402 for bad in ["", "../x", ".hidden", "a b"] {
403 refusal(&with(|v| v["run_id"] = json!(bad)));
404 }
405 }
406
407 #[test]
408 fn refuses_an_unknown_state() {
409 let text = refusal(&with(|v| v["state"] = json!("halfway")));
410 assert!(text.contains("state"), "{text}");
411 }
412
413 #[test]
414 fn refuses_unsorted_or_duplicate_touched() {
415 let text = refusal(&with(|v| {
416 let t = v["touched"].as_array_mut().expect("array");
417 t.swap(1, 2);
418 }));
419 assert!(text.contains("sorted"), "{text}");
420 let text = refusal(&with(|v| {
421 let first = v["touched"][1].clone();
422 v["touched"][2] = first;
423 }));
424 assert!(text.contains("duplicate"), "{text}");
425 }
426
427 #[test]
428 fn refuses_a_touched_path_that_is_not_a_collection_path() {
429 for bad in [
430 "a/b",
431 "mod/type/.hidden/x",
432 "mod/type/../x",
433 "/abs/x/y",
434 "ledger.json.bak",
435 ] {
436 refusal(&with(|v| {
437 v["touched"] = json!([{"path": bad, "pre_sha256": null, "post_sha256": null}])
438 }));
439 }
440 }
441
442 #[test]
443 fn refuses_a_hash_that_is_not_hex() {
444 for bad in ["", "abc", &H1.to_uppercase(), &format!("{H1}0")] {
445 refusal(&with(|v| v["render_sha256"] = json!(bad)));
446 refusal(&with(|v| v["plan_sha256"] = json!(bad)));
447 refusal(&with(|v| v["pre_fingerprint"] = json!(bad)));
448 refusal(&with(|v| v["post_fingerprint"] = json!(bad)));
449 refusal(&with(|v| v["touched"][1]["pre_sha256"] = json!(bad)));
450 refusal(&with(|v| v["touched"][1]["post_sha256"] = json!(bad)));
451 }
452 }
453
454 #[test]
455 fn refuses_a_post_fingerprint_while_applying() {
456 let text = refusal(&with(|v| v["state"] = json!("applying")));
457 assert!(text.contains("post_fingerprint"), "{text}");
458 }
459
460 #[test]
461 fn refuses_rolled_back_by_unless_rolled_back() {
462 for state in ["applying", "applied"] {
463 let text = refusal(&with(|v| {
464 v["state"] = json!(state);
465 v["post_fingerprint"] = Value::Null;
466 v["rolled_back_by"] = json!("20261016T090000Z-ffffff");
467 }));
468 assert!(text.contains("rolled_back_by"), "{state}: {text}");
469 }
470 }
471
472 #[test]
473 fn refuses_a_bad_rolled_back_by() {
474 refusal(&with(|v| {
475 v["state"] = json!("rolled_back");
476 v["rolled_back_by"] = json!("../x");
477 }));
478 }
479
480 #[test]
481 fn refuses_a_wrong_typed_field() {
482 refusal(&with(|v| v["touched"] = json!("mod/type/a.json")));
483 refusal(&with(|v| v["touched"][1]["path"] = json!(7)));
484 }
485
486 #[test]
487 fn refuses_a_top_level_that_is_not_an_object() {
488 refusal(b"[]");
489 }
490}