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