1use std::collections::BTreeSet;
72
73use serde::{Deserialize, Serialize};
74
75use super::apply::journal::JOURNAL_FILE;
76use super::apply::record::RECORD_FILE;
77use super::layout::{KeyCollection, RelPath, TAG_TYPES, is_pair_unsynced_tag_path, split_key};
78use super::node::{self, Node};
79use super::rules::{GATEWAY_OWNED_FILES, openapi_describes};
80use crate::error::CoreError;
81
82pub const LEDGER_FILE: &str = "ledger.json";
84
85pub const LEDGER_MAX_BYTES: usize = 8_388_608;
87
88const SCHEMA_VERSION: u32 = 1;
90
91const RUN_ID_MAX: usize = 128;
93
94const FIELDS: [&str; 6] = [
96 "schema_version",
97 "gateway",
98 "mode",
99 "render_sha256",
100 "run_id",
101 "files",
102];
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct Ledger {
108 pub schema_version: u32,
110 pub gateway: String,
113 pub mode: String,
115 pub render_sha256: String,
118 pub run_id: String,
120 pub files: Vec<String>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum LedgerRoot {
127 DataDir,
129 AccountHome,
131}
132
133impl LedgerRoot {
134 pub fn for_node(node: &super::target::NodeTarget) -> LedgerRoot {
138 if node.ssh.is_some() {
139 LedgerRoot::AccountHome
140 } else {
141 LedgerRoot::DataDir
142 }
143 }
144}
145
146pub fn ledger_rel(gateway: &str, mode: &str) -> String {
148 format!("ign-deploy/{gateway}/{mode}/{LEDGER_FILE}")
149}
150
151pub fn staging_rel(gateway: &str, mode: &str, run_id: &str) -> String {
154 format!("ign-deploy/{gateway}/{mode}/staging/{run_id}")
155}
156
157pub fn journal_rel(gateway: &str, mode: &str) -> String {
162 format!("ign-deploy/{gateway}/{mode}/{JOURNAL_FILE}")
163}
164
165pub fn snapshot_rel(gateway: &str, mode: &str, run_id: &str) -> String {
169 format!("ign-deploy/{gateway}/{mode}/snapshots/{run_id}")
170}
171
172pub fn record_rel(gateway: &str, mode: &str, run_id: &str) -> String {
176 format!("{}/{RECORD_FILE}", snapshot_rel(gateway, mode, run_id))
177}
178
179pub const RELEASE_RECORD_FILE: &str = "release.json";
181
182pub const RELEASE_RECORD_MAX_BYTES: usize = 65_536;
184
185pub fn release_record_rel(gateway: &str, project: &str) -> String {
194 format!("ign-deploy/{gateway}/projects/{project}/{RELEASE_RECORD_FILE}")
195}
196
197pub fn release_record_location(root: LedgerRoot, gateway: &str, project: &str) -> String {
200 location_of(root, &release_record_rel(gateway, project))
201}
202
203pub fn location_of(root: LedgerRoot, rel: &str) -> String {
207 match root {
208 LedgerRoot::DataDir => rel.to_string(),
209 LedgerRoot::AccountHome => format!("~/{rel}"),
210 }
211}
212
213pub fn ledger_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
217 location_of(root, &ledger_rel(gateway, mode))
218}
219
220pub fn journal_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
223 location_of(root, &journal_rel(gateway, mode))
224}
225
226fn shown(text: &str) -> String {
228 text.chars().filter(|c| !c.is_control()).take(64).collect()
229}
230
231impl Ledger {
232 pub fn parse(
236 location: &str,
237 bytes: &[u8],
238 gateway: &str,
239 mode: &str,
240 ) -> Result<Ledger, CoreError> {
241 let bad = |problem: String| CoreError::DeployLedgerInvalid {
242 location: location.to_string(),
243 problem,
244 };
245 if bytes.is_empty() {
246 return Err(bad("the file is empty".to_string()));
247 }
248 if bytes.len() > LEDGER_MAX_BYTES {
249 return Err(bad(format!(
250 "the file is {} bytes, over the {LEDGER_MAX_BYTES} byte cap",
251 bytes.len()
252 )));
253 }
254 let (_, root) = node::classify(bytes).map_err(bad)?;
256 let Node::Obj(members) = &root else {
257 return Err(bad("the top level is not a JSON object".to_string()));
258 };
259 for (key, _) in members {
260 if !FIELDS.contains(&key.as_str()) {
261 return Err(bad(format!("unknown field `{}`", shown(key))));
262 }
263 }
264 for field in FIELDS {
265 if root.get(field).is_none() {
266 return Err(bad(format!("missing field `{field}`")));
267 }
268 }
269 let ledger: Ledger = serde_json::from_slice(bytes).map_err(|e| {
270 bad(format!(
271 "a field has the wrong type (line {} column {})",
272 e.line(),
273 e.column()
274 ))
275 })?;
276
277 if ledger.schema_version != SCHEMA_VERSION {
278 return Err(bad(format!(
279 "schema_version is not {SCHEMA_VERSION}, the only version this build reads"
280 )));
281 }
282 if ledger.gateway != gateway {
283 return Err(bad(format!(
284 "gateway names a different gateway than the target (`{}`)",
285 shown(gateway)
286 )));
287 }
288 if ledger.mode != mode {
289 return Err(bad(format!(
290 "mode names a different mode than the target (`{}`)",
291 shown(mode)
292 )));
293 }
294 let hex = ledger.render_sha256.len() == 64
295 && ledger
296 .render_sha256
297 .bytes()
298 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
299 if !hex {
300 return Err(bad(
301 "render_sha256 is not 64 lowercase hex digits".to_string()
302 ));
303 }
304 let run_id_ok = (1..=RUN_ID_MAX).contains(&ledger.run_id.len())
305 && ledger
306 .run_id
307 .bytes()
308 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
309 if !run_id_ok {
310 return Err(bad(format!(
311 "run_id is not 1 to {RUN_ID_MAX} characters of [A-Za-z0-9._-]"
312 )));
313 }
314 check_files(&ledger.files).map_err(bad)?;
315 Ok(ledger)
316 }
317
318 pub fn to_bytes(&self) -> Vec<u8> {
321 let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
322 bytes.push(b'\n');
323 bytes
324 }
325}
326
327fn declared_type(module: &str, ty: &str) -> bool {
329 openapi_describes(module, ty) || (module == "ignition" && TAG_TYPES.contains(&ty))
330}
331
332fn check_files(files: &[String]) -> Result<(), String> {
334 let mut folded: BTreeSet<String> = BTreeSet::new();
335 let mut previous: Option<&String> = None;
336 for (index, path) in files.iter().enumerate() {
337 let listed = shown(path);
338 if let Err(why) = RelPath::new(path) {
339 return Err(format!(
340 "files[{index}] `{listed}` is not a safe path ({why})"
341 ));
342 }
343 let (collection, rest) = split_key(path);
344 if collection == KeyCollection::Core && !is_pair_unsynced_tag_path(rest) {
345 return Err(format!(
346 "files[{index}] `{listed}` names the core collection outside ignition/tag-definition and ignition/tag-type-definition"
347 ));
348 }
349 let parts: Vec<&str> = rest.split('/').collect();
350 if let Some(owned) = parts.iter().find(|p| GATEWAY_OWNED_FILES.contains(*p)) {
351 return Err(format!(
352 "files[{index}] `{listed}` lists the gateway-owned file `{owned}`, which is never deleted"
353 ));
354 }
355 if parts.iter().any(|p| p.starts_with('.')) {
356 return Err(format!(
357 "files[{index}] `{listed}` has a dot-named component"
358 ));
359 }
360 if parts.len() < 3 {
361 return Err(format!(
362 "files[{index}] `{listed}` has fewer than three components"
363 ));
364 }
365 if !declared_type(parts[0], parts[1]) {
366 return Err(format!(
367 "files[{index}] `{listed}` is under type `{}/{}`, which is not a declared resource type",
368 shown(parts[0]),
369 shown(parts[1])
370 ));
371 }
372 if let Some(prev) = previous {
373 if path == prev {
374 return Err(format!(
375 "files[{index}] `{listed}` is listed twice (duplicate)"
376 ));
377 }
378 if path.as_bytes() < prev.as_bytes() {
379 return Err(format!(
380 "files is not byte-sorted: `{listed}` follows `{}`",
381 shown(prev)
382 ));
383 }
384 }
385 if !folded.insert(path.to_ascii_lowercase()) {
386 return Err(format!(
387 "files[{index}] `{listed}` differs from another entry only by case, which a case-insensitive host treats as one file"
388 ));
389 }
390 previous = Some(path);
391 }
392 Ok(())
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 const GW: &str = "tracer-gw";
400 const MODE: &str = "dev";
401 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
402
403 fn loc() -> String {
404 ledger_location(LedgerRoot::DataDir, GW, MODE)
405 }
406
407 fn ledger(files: &[&str]) -> Ledger {
408 Ledger {
409 schema_version: 1,
410 gateway: GW.to_string(),
411 mode: MODE.to_string(),
412 render_sha256: HASH.to_string(),
413 run_id: "run-1.a_b".to_string(),
414 files: files.iter().map(|f| (*f).to_string()).collect(),
415 }
416 }
417
418 fn refused(bytes: &[u8]) -> String {
419 let err = Ledger::parse(&loc(), bytes, GW, MODE).expect_err("must be refused");
420 assert_eq!(err.code(), "deploy_ledger_invalid", "{err}");
421 assert_eq!(err.exit_code(), 6);
422 let text = err.to_string();
423 assert!(text.contains(&loc()), "names the location: {text}");
424 text
425 }
426
427 fn refused_ledger(l: &Ledger) -> String {
428 refused(&l.to_bytes())
429 }
430
431 fn json_with(replace: (&str, &str)) -> Vec<u8> {
432 let text =
433 String::from_utf8(ledger(&["ignition/database-translator/A/config.json"]).to_bytes())
434 .expect("utf8");
435 assert!(text.contains(replace.0), "{text}");
436 text.replace(replace.0, replace.1).into_bytes()
437 }
438
439 #[test]
443 fn journal_snapshot_and_record_paths_are_pinned() {
444 assert_eq!(journal_rel("gw", "prod"), "ign-deploy/gw/prod/journal.json");
445 assert_eq!(
446 snapshot_rel("gw", "prod", "R"),
447 "ign-deploy/gw/prod/snapshots/R"
448 );
449 assert_eq!(
450 record_rel("gw", "prod", "R"),
451 "ign-deploy/gw/prod/snapshots/R/record.json"
452 );
453 for rel in [
454 journal_rel("gw", "prod"),
455 snapshot_rel("gw", "prod", "R"),
456 record_rel("gw", "prod", "R"),
457 ] {
458 assert!(rel.starts_with("ign-deploy/gw/prod/"), "{rel}");
459 assert!(!rel.contains("config/resources"), "{rel}");
460 assert!(rel.split('/').all(|p| !p.starts_with('.')), "{rel}");
461 }
462 assert_eq!(
463 journal_location(LedgerRoot::AccountHome, "gw", "prod"),
464 "~/ign-deploy/gw/prod/journal.json"
465 );
466 assert_eq!(
467 journal_location(LedgerRoot::DataDir, "gw", "prod"),
468 "ign-deploy/gw/prod/journal.json"
469 );
470 }
471
472 #[test]
473 fn location_is_below_ign_deploy() {
474 assert_eq!(loc(), "ign-deploy/tracer-gw/dev/ledger.json");
475 }
476
477 #[test]
481 fn ledger_location_follows_decision_b() {
482 use crate::deploy::target::{Local, NodeTarget, Ssh, TargetOs};
483 assert_eq!(
484 ledger_location(LedgerRoot::DataDir, GW, MODE),
485 "ign-deploy/tracer-gw/dev/ledger.json"
486 );
487 assert_eq!(
488 ledger_location(LedgerRoot::AccountHome, GW, MODE),
489 "~/ign-deploy/tracer-gw/dev/ledger.json"
490 );
491 assert_eq!(ledger_rel(GW, MODE), "ign-deploy/tracer-gw/dev/ledger.json");
492 assert_eq!(
493 staging_rel(GW, MODE, "run-1"),
494 "ign-deploy/tracer-gw/dev/staging/run-1"
495 );
496 assert!(staging_rel(GW, MODE, "r").starts_with("ign-deploy/tracer-gw/dev/"));
499 assert!(!ledger_rel(GW, MODE).contains("config/resources"));
500 assert!(ledger_rel(GW, MODE).split('/').all(|p| !p.starts_with('.')));
501
502 let ssh = NodeTarget {
503 profile: "p".to_string(),
504 ssh: Some(Ssh {
505 host: "h".to_string(),
506 user: "u".to_string(),
507 port: None,
508 key_env: None,
509 key_file: None,
510 os: TargetOs::Windows,
511 data_dir: "C:\\ignition\\data".to_string(),
512 known_hosts: "known_hosts".to_string(),
513 connect_timeout_s: None,
514 op_timeout_s: None,
515 }),
516 local: None,
517 gateway_name: None,
518 };
519 let local = NodeTarget {
520 profile: "p".to_string(),
521 ssh: None,
522 local: Some(Local {
523 data_dir: "/data".to_string(),
524 }),
525 gateway_name: None,
526 };
527 assert_eq!(LedgerRoot::for_node(&ssh), LedgerRoot::AccountHome);
528 assert_eq!(LedgerRoot::for_node(&local), LedgerRoot::DataDir);
529 }
530
531 #[test]
532 fn parse_accepts_v1() {
533 let l = ledger(&[
534 "ignition/database-translator/A/config.json",
535 "ignition/database-translator/A/resource.json",
536 "ignition/tag-definition/P/tags.json",
537 ]);
538 let bytes = l.to_bytes();
539 assert!(bytes.ends_with(b"\n"));
540 let parsed = Ledger::parse(&loc(), &bytes, GW, MODE).expect("v1 parses");
541 assert_eq!(parsed, l);
542 assert_eq!(parsed.to_bytes(), bytes, "byte-identical round trip");
543 }
544
545 #[test]
546 fn files_empty_is_valid() {
547 let l = ledger(&[]);
548 let parsed = Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("empty files is valid");
549 assert!(parsed.files.is_empty());
550 }
551
552 #[test]
553 fn refuses_empty_file() {
554 assert!(refused(b"").contains("empty"));
555 }
556
557 #[test]
558 fn refuses_over_the_cap() {
559 let mut bytes = vec![b' '; LEDGER_MAX_BYTES + 1];
560 bytes[0] = b'{';
561 let text = refused(&bytes);
562 assert!(text.contains("8388608"), "{text}");
563 }
564
565 #[test]
566 fn refuses_invalid_utf8() {
567 let mut bytes = ledger(&[]).to_bytes();
568 bytes.insert(2, 0xff);
569 assert!(refused(&bytes).contains("UTF-8"));
570 }
571
572 #[test]
573 fn refuses_duplicate_key() {
574 let bytes = json_with((
575 "\"mode\": \"dev\"",
576 "\"mode\": \"dev\",\n \"mode\": \"dev\"",
577 ));
578 assert!(refused(&bytes).to_lowercase().contains("duplicate"));
579 }
580
581 #[test]
582 fn refuses_unknown_field() {
583 let bytes = json_with((
584 "\"schema_version\": 1",
585 "\"schema_version\": 1,\n \"extra\": 1",
586 ));
587 let text = refused(&bytes);
588 assert!(
589 text.contains("unknown field") && text.contains("extra"),
590 "{text}"
591 );
592 }
593
594 #[test]
595 fn refuses_a_missing_field() {
596 let bytes = json_with(("\"run_id\": \"run-1.a_b\",", ""));
597 assert!(refused(&bytes).contains("run_id"));
598 }
599
600 #[test]
601 fn refuses_a_wrong_field_type_without_echoing_it() {
602 let bytes = json_with((
603 "\"schema_version\": 1",
604 "\"schema_version\": \"leaky-value\"",
605 ));
606 let text = refused(&bytes);
607 assert!(!text.contains("leaky-value"), "{text}");
608 }
609
610 #[test]
611 fn refuses_a_non_object() {
612 refused(b"[]\n");
613 }
614
615 #[test]
616 fn refuses_another_schema_version() {
617 let mut l = ledger(&[]);
618 l.schema_version = 2;
619 assert!(refused_ledger(&l).contains("schema_version"));
620 }
621
622 #[test]
623 fn refuses_another_gateway_without_echoing_it() {
624 let mut l = ledger(&[]);
625 l.gateway = "other-gw".to_string();
626 let text = refused_ledger(&l);
627 assert!(text.contains("gateway"), "{text}");
628 assert!(!text.contains("other-gw"), "{text}");
629 }
630
631 #[test]
632 fn refuses_another_mode_without_echoing_it() {
633 let mut l = ledger(&[]);
634 l.mode = "prod".to_string();
635 let text = refused_ledger(&l);
636 assert!(text.contains("mode"), "{text}");
637 assert!(!text.contains("prod"), "{text}");
638 }
639
640 #[test]
641 fn refuses_a_bad_render_hash_and_run_id() {
642 let mut l = ledger(&[]);
643 l.render_sha256 = "ABC".to_string();
644 assert!(refused_ledger(&l).contains("render_sha256"));
645 let mut l = ledger(&[]);
646 l.render_sha256 = HASH.to_uppercase();
647 assert!(refused_ledger(&l).contains("render_sha256"));
648 for bad in ["", "a b", "a/b", &"x".repeat(129)] {
649 let mut l = ledger(&[]);
650 l.run_id = bad.to_string();
651 assert!(refused_ledger(&l).contains("run_id"), "{bad:?}");
652 }
653 let mut l = ledger(&[]);
654 l.run_id = "x".repeat(128);
655 Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("128 characters fit");
656 }
657
658 #[test]
659 fn refuses_unsorted_files() {
660 let l = ledger(&[
661 "ignition/database-translator/B/config.json",
662 "ignition/database-translator/A/config.json",
663 ]);
664 assert!(refused_ledger(&l).contains("sorted"));
665 }
666
667 #[test]
668 fn refuses_duplicate_files() {
669 let l = ledger(&[
670 "ignition/database-translator/A/config.json",
671 "ignition/database-translator/A/config.json",
672 ]);
673 assert!(refused_ledger(&l).contains("duplicate"));
674 }
675
676 #[test]
677 fn refuses_case_variant_duplicates() {
678 let l = ledger(&[
679 "ignition/database-translator/X/config.json",
680 "ignition/database-translator/x/config.json",
681 ]);
682 let text = refused_ledger(&l);
683 assert!(text.contains("case"), "{text}");
684 }
685
686 #[test]
687 fn refuses_config_mode_json() {
688 let l = ledger(&["config-mode.json"]);
689 assert!(refused_ledger(&l).contains("config-mode.json"));
690 let l = ledger(&["ignition/database-translator/A/config-mode.json"]);
691 assert!(refused_ledger(&l).contains("config-mode.json"));
692 }
693
694 #[test]
695 fn refuses_dot_named_components() {
696 for bad in [
697 "ignition/database-translator/.hidden/config.json",
698 "ignition/.git/config.json",
699 ".x/database-translator/A/config.json",
700 ] {
701 let text = refused_ledger(&ledger(&[bad]));
702 assert!(text.contains("dot"), "{bad}: {text}");
703 }
704 }
705
706 #[test]
707 fn refuses_fewer_than_three_components() {
708 for bad in ["ignition", "ignition/database-translator"] {
709 let text = refused_ledger(&ledger(&[bad]));
710 assert!(text.contains("three"), "{bad}: {text}");
711 }
712 }
713
714 #[test]
715 fn refuses_unsafe_paths() {
716 for bad in ["../x/y/z", "ignition/database-translator//A", "a\\b/c/d"] {
718 refused_ledger(&ledger(&[bad]));
719 }
720 }
721
722 #[test]
723 fn refuses_a_type_that_is_not_described() {
724 let l = ledger(&["acme/widget/A/config.json"]);
725 let text = refused_ledger(&l);
726 assert!(text.contains("acme/widget/A/config.json"), "{text}");
727 assert!(text.contains("type"), "{text}");
728 }
729
730 #[test]
731 fn tag_types_are_declared_resource_paths() {
732 let l = ledger(&[
733 "ignition/tag-definition/P/tags.json",
734 "ignition/tag-group/P/config.json",
735 "ignition/tag-provider/P/config.json",
736 "ignition/tag-type-definition/P/udts.json",
737 ]);
738 Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("tag types are described");
739 }
740
741 #[test]
744 fn core_key_ledger_accepts_only_routed_tag_types() {
745 let l = ledger(&[
746 ".core/ignition/tag-definition/default/Deploy/tags.json",
747 ".core/ignition/tag-type-definition/default/T/udts.json",
748 "ignition/database-translator/A/config.json",
749 ]);
750 let parsed = Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("core tag keys");
751 assert_eq!(parsed, l);
752 for bad in [
753 ".core/ignition/tag-provider/default/config.json",
754 ".core/ignition/database-translator/A/config.json",
755 ".core/ignition/tag-group/default/G/config.json",
756 ] {
757 let text = refused_ledger(&ledger(&[bad]));
758 assert!(text.contains("core collection"), "{bad}: {text}");
759 }
760 for bad in [
762 ".core/ignition/tag-definition/default/.x/tags.json",
763 ".core/.core/ignition/tag-definition/a/b",
764 ] {
765 refused_ledger(&ledger(&[bad]));
766 }
767 }
768
769 #[test]
773 fn core_key_ledger_case_variants_per_collection() {
774 let both = ledger(&[
775 ".core/ignition/tag-definition/P/A/tags.json",
776 "ignition/tag-definition/P/a/tags.json",
777 ]);
778 Ledger::parse(&loc(), &both.to_bytes(), GW, MODE).expect("two collections");
779 let same = ledger(&[
780 ".core/ignition/tag-definition/P/A/tags.json",
781 ".core/ignition/tag-definition/P/a/tags.json",
782 ]);
783 assert!(refused_ledger(&same).contains("case"));
784 }
785}