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 project_journal_rel(gateway: &str, project: &str) -> String {
208 format!(
209 "ign-deploy/{gateway}/projects/{project}/{}",
210 super::project_run::PROJECT_JOURNAL_FILE
211 )
212}
213
214pub fn project_journal_location(root: LedgerRoot, gateway: &str, project: &str) -> String {
216 location_of(root, &project_journal_rel(gateway, project))
217}
218
219pub fn pre_image_rel(gateway: &str, project: &str, name: &str) -> String {
223 format!(
224 "ign-deploy/{gateway}/projects/{project}/{}/{name}",
225 super::project_run::PRE_IMAGE_DIR
226 )
227}
228
229pub fn pre_image_location(root: LedgerRoot, gateway: &str, project: &str, name: &str) -> String {
231 location_of(root, &pre_image_rel(gateway, project, name))
232}
233
234pub fn tag_record_rel(gateway: &str, provider: &str) -> String {
245 format!(
246 "ign-deploy/{gateway}/{TAG_RECORDS_DIR}/{provider}/{}",
247 super::tag_record::TAG_RECORD_FILE
248 )
249}
250
251pub const TAG_RECORDS_DIR: &str = "tag-providers";
253
254pub fn tag_record_location(root: LedgerRoot, gateway: &str, provider: &str) -> String {
257 location_of(root, &tag_record_rel(gateway, provider))
258}
259
260pub fn location_of(root: LedgerRoot, rel: &str) -> String {
264 match root {
265 LedgerRoot::DataDir => rel.to_string(),
266 LedgerRoot::AccountHome => format!("~/{rel}"),
267 }
268}
269
270pub fn ledger_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
274 location_of(root, &ledger_rel(gateway, mode))
275}
276
277pub fn journal_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
280 location_of(root, &journal_rel(gateway, mode))
281}
282
283fn shown(text: &str) -> String {
285 text.chars().filter(|c| !c.is_control()).take(64).collect()
286}
287
288impl Ledger {
289 pub fn parse(
293 location: &str,
294 bytes: &[u8],
295 gateway: &str,
296 mode: &str,
297 ) -> Result<Ledger, CoreError> {
298 let bad = |problem: String| CoreError::DeployLedgerInvalid {
299 location: location.to_string(),
300 problem,
301 };
302 if bytes.is_empty() {
303 return Err(bad("the file is empty".to_string()));
304 }
305 if bytes.len() > LEDGER_MAX_BYTES {
306 return Err(bad(format!(
307 "the file is {} bytes, over the {LEDGER_MAX_BYTES} byte cap",
308 bytes.len()
309 )));
310 }
311 let (_, root) = node::classify(bytes).map_err(bad)?;
313 let Node::Obj(members) = &root else {
314 return Err(bad("the top level is not a JSON object".to_string()));
315 };
316 for (key, _) in members {
317 if !FIELDS.contains(&key.as_str()) {
318 return Err(bad(format!("unknown field `{}`", shown(key))));
319 }
320 }
321 for field in FIELDS {
322 if root.get(field).is_none() {
323 return Err(bad(format!("missing field `{field}`")));
324 }
325 }
326 let ledger: Ledger = serde_json::from_slice(bytes).map_err(|e| {
327 bad(format!(
328 "a field has the wrong type (line {} column {})",
329 e.line(),
330 e.column()
331 ))
332 })?;
333
334 if ledger.schema_version != SCHEMA_VERSION {
335 return Err(bad(format!(
336 "schema_version is not {SCHEMA_VERSION}, the only version this build reads"
337 )));
338 }
339 if ledger.gateway != gateway {
340 return Err(bad(format!(
341 "gateway names a different gateway than the target (`{}`)",
342 shown(gateway)
343 )));
344 }
345 if ledger.mode != mode {
346 return Err(bad(format!(
347 "mode names a different mode than the target (`{}`)",
348 shown(mode)
349 )));
350 }
351 let hex = ledger.render_sha256.len() == 64
352 && ledger
353 .render_sha256
354 .bytes()
355 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
356 if !hex {
357 return Err(bad(
358 "render_sha256 is not 64 lowercase hex digits".to_string()
359 ));
360 }
361 let run_id_ok = (1..=RUN_ID_MAX).contains(&ledger.run_id.len())
362 && ledger
363 .run_id
364 .bytes()
365 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
366 if !run_id_ok {
367 return Err(bad(format!(
368 "run_id is not 1 to {RUN_ID_MAX} characters of [A-Za-z0-9._-]"
369 )));
370 }
371 check_files(&ledger.files).map_err(bad)?;
372 Ok(ledger)
373 }
374
375 pub fn to_bytes(&self) -> Vec<u8> {
378 let mut bytes = serde_json::to_vec_pretty(self).unwrap_or_default();
379 bytes.push(b'\n');
380 bytes
381 }
382}
383
384fn declared_type(module: &str, ty: &str) -> bool {
386 openapi_describes(module, ty) || (module == "ignition" && TAG_TYPES.contains(&ty))
387}
388
389fn check_files(files: &[String]) -> Result<(), String> {
391 let mut folded: BTreeSet<String> = BTreeSet::new();
392 let mut previous: Option<&String> = None;
393 for (index, path) in files.iter().enumerate() {
394 let listed = shown(path);
395 if let Err(why) = RelPath::new(path) {
396 return Err(format!(
397 "files[{index}] `{listed}` is not a safe path ({why})"
398 ));
399 }
400 let (collection, rest) = split_key(path);
401 if collection == KeyCollection::Core && !is_pair_unsynced_tag_path(rest) {
402 return Err(format!(
403 "files[{index}] `{listed}` names the core collection outside ignition/tag-definition and ignition/tag-type-definition"
404 ));
405 }
406 let parts: Vec<&str> = rest.split('/').collect();
407 if let Some(owned) = parts.iter().find(|p| GATEWAY_OWNED_FILES.contains(*p)) {
408 return Err(format!(
409 "files[{index}] `{listed}` lists the gateway-owned file `{owned}`, which is never deleted"
410 ));
411 }
412 if parts.iter().any(|p| p.starts_with('.')) {
413 return Err(format!(
414 "files[{index}] `{listed}` has a dot-named component"
415 ));
416 }
417 if parts.len() < 3 {
418 return Err(format!(
419 "files[{index}] `{listed}` has fewer than three components"
420 ));
421 }
422 if !declared_type(parts[0], parts[1]) {
423 return Err(format!(
424 "files[{index}] `{listed}` is under type `{}/{}`, which is not a declared resource type",
425 shown(parts[0]),
426 shown(parts[1])
427 ));
428 }
429 if let Some(prev) = previous {
430 if path == prev {
431 return Err(format!(
432 "files[{index}] `{listed}` is listed twice (duplicate)"
433 ));
434 }
435 if path.as_bytes() < prev.as_bytes() {
436 return Err(format!(
437 "files is not byte-sorted: `{listed}` follows `{}`",
438 shown(prev)
439 ));
440 }
441 }
442 if !folded.insert(path.to_ascii_lowercase()) {
443 return Err(format!(
444 "files[{index}] `{listed}` differs from another entry only by case, which a case-insensitive host treats as one file"
445 ));
446 }
447 previous = Some(path);
448 }
449 Ok(())
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 const GW: &str = "tracer-gw";
457 const MODE: &str = "dev";
458 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
459
460 fn loc() -> String {
461 ledger_location(LedgerRoot::DataDir, GW, MODE)
462 }
463
464 fn ledger(files: &[&str]) -> Ledger {
465 Ledger {
466 schema_version: 1,
467 gateway: GW.to_string(),
468 mode: MODE.to_string(),
469 render_sha256: HASH.to_string(),
470 run_id: "run-1.a_b".to_string(),
471 files: files.iter().map(|f| (*f).to_string()).collect(),
472 }
473 }
474
475 fn refused(bytes: &[u8]) -> String {
476 let err = Ledger::parse(&loc(), bytes, GW, MODE).expect_err("must be refused");
477 assert_eq!(err.code(), "deploy_ledger_invalid", "{err}");
478 assert_eq!(err.exit_code(), 6);
479 let text = err.to_string();
480 assert!(text.contains(&loc()), "names the location: {text}");
481 text
482 }
483
484 fn refused_ledger(l: &Ledger) -> String {
485 refused(&l.to_bytes())
486 }
487
488 fn json_with(replace: (&str, &str)) -> Vec<u8> {
489 let text =
490 String::from_utf8(ledger(&["ignition/database-translator/A/config.json"]).to_bytes())
491 .expect("utf8");
492 assert!(text.contains(replace.0), "{text}");
493 text.replace(replace.0, replace.1).into_bytes()
494 }
495
496 #[test]
500 fn journal_snapshot_and_record_paths_are_pinned() {
501 assert_eq!(journal_rel("gw", "prod"), "ign-deploy/gw/prod/journal.json");
502 assert_eq!(
503 snapshot_rel("gw", "prod", "R"),
504 "ign-deploy/gw/prod/snapshots/R"
505 );
506 assert_eq!(
507 record_rel("gw", "prod", "R"),
508 "ign-deploy/gw/prod/snapshots/R/record.json"
509 );
510 for rel in [
511 journal_rel("gw", "prod"),
512 snapshot_rel("gw", "prod", "R"),
513 record_rel("gw", "prod", "R"),
514 ] {
515 assert!(rel.starts_with("ign-deploy/gw/prod/"), "{rel}");
516 assert!(!rel.contains("config/resources"), "{rel}");
517 assert!(rel.split('/').all(|p| !p.starts_with('.')), "{rel}");
518 }
519 assert_eq!(
520 journal_location(LedgerRoot::AccountHome, "gw", "prod"),
521 "~/ign-deploy/gw/prod/journal.json"
522 );
523 assert_eq!(
524 journal_location(LedgerRoot::DataDir, "gw", "prod"),
525 "ign-deploy/gw/prod/journal.json"
526 );
527 }
528
529 #[test]
530 fn location_is_below_ign_deploy() {
531 assert_eq!(loc(), "ign-deploy/tracer-gw/dev/ledger.json");
532 }
533
534 #[test]
538 fn tag_record_location_is_beside_the_release_records() {
539 assert_eq!(
540 tag_record_rel("gw-a", "PlantA"),
541 "ign-deploy/gw-a/tag-providers/PlantA/record.json"
542 );
543 let release = release_record_rel("gw-a", "CapApp");
544 let root = release
545 .strip_suffix("projects/CapApp/release.json")
546 .expect("the release record root");
547 assert_eq!(
548 tag_record_rel("gw-a", "PlantA"),
549 format!("{root}tag-providers/PlantA/record.json")
550 );
551 assert_eq!(
552 tag_record_location(LedgerRoot::DataDir, "gw-a", "PlantA"),
553 "ign-deploy/gw-a/tag-providers/PlantA/record.json"
554 );
555 assert_eq!(
556 tag_record_location(LedgerRoot::AccountHome, "gw-a", "PlantA"),
557 "~/ign-deploy/gw-a/tag-providers/PlantA/record.json"
558 );
559 let rel = tag_record_rel("gw-a", "PlantA");
560 assert!(!rel.contains("config/resources"), "{rel}");
561 assert!(rel.split('/').all(|p| !p.starts_with('.')), "{rel}");
562 }
563
564 #[test]
567 fn project_run_locations_are_beside_the_release_record() {
568 let release = release_record_rel("gw-a", "CapApp");
569 let dir = release.strip_suffix("release.json").expect("record dir");
570 assert_eq!(
571 project_journal_rel("gw-a", "CapApp"),
572 format!("{dir}journal.json")
573 );
574 assert_eq!(
575 pre_image_rel("gw-a", "CapApp", "tags-PlantA.json"),
576 format!("{dir}pre-image/tags-PlantA.json")
577 );
578 assert_eq!(
579 project_journal_location(LedgerRoot::AccountHome, "gw-a", "CapApp"),
580 "~/ign-deploy/gw-a/projects/CapApp/journal.json"
581 );
582 assert_eq!(
583 pre_image_location(LedgerRoot::DataDir, "gw-a", "CapApp", "run.json"),
584 "ign-deploy/gw-a/projects/CapApp/pre-image/run.json"
585 );
586 for rel in [
587 project_journal_rel("gw-a", "CapApp"),
588 pre_image_rel("gw-a", "CapApp", "run.json"),
589 ] {
590 assert!(!rel.contains("config/resources"), "{rel}");
591 assert!(rel.split('/').all(|p| !p.starts_with('.')), "{rel}");
592 }
593 }
594
595 #[test]
599 fn ledger_location_follows_decision_b() {
600 use crate::deploy::target::{Local, NodeTarget, Ssh, TargetOs};
601 assert_eq!(
602 ledger_location(LedgerRoot::DataDir, GW, MODE),
603 "ign-deploy/tracer-gw/dev/ledger.json"
604 );
605 assert_eq!(
606 ledger_location(LedgerRoot::AccountHome, GW, MODE),
607 "~/ign-deploy/tracer-gw/dev/ledger.json"
608 );
609 assert_eq!(ledger_rel(GW, MODE), "ign-deploy/tracer-gw/dev/ledger.json");
610 assert_eq!(
611 staging_rel(GW, MODE, "run-1"),
612 "ign-deploy/tracer-gw/dev/staging/run-1"
613 );
614 assert!(staging_rel(GW, MODE, "r").starts_with("ign-deploy/tracer-gw/dev/"));
617 assert!(!ledger_rel(GW, MODE).contains("config/resources"));
618 assert!(ledger_rel(GW, MODE).split('/').all(|p| !p.starts_with('.')));
619
620 let ssh = NodeTarget {
621 profile: "p".to_string(),
622 ssh: Some(Ssh {
623 host: "h".to_string(),
624 user: "u".to_string(),
625 port: None,
626 key_env: None,
627 key_file: None,
628 os: TargetOs::Windows,
629 data_dir: "C:\\ignition\\data".to_string(),
630 known_hosts: "known_hosts".to_string(),
631 connect_timeout_s: None,
632 op_timeout_s: None,
633 }),
634 local: None,
635 gateway_name: None,
636 };
637 let local = NodeTarget {
638 profile: "p".to_string(),
639 ssh: None,
640 local: Some(Local {
641 data_dir: "/data".to_string(),
642 }),
643 gateway_name: None,
644 };
645 assert_eq!(LedgerRoot::for_node(&ssh), LedgerRoot::AccountHome);
646 assert_eq!(LedgerRoot::for_node(&local), LedgerRoot::DataDir);
647 }
648
649 #[test]
650 fn parse_accepts_v1() {
651 let l = ledger(&[
652 "ignition/database-translator/A/config.json",
653 "ignition/database-translator/A/resource.json",
654 "ignition/tag-definition/P/tags.json",
655 ]);
656 let bytes = l.to_bytes();
657 assert!(bytes.ends_with(b"\n"));
658 let parsed = Ledger::parse(&loc(), &bytes, GW, MODE).expect("v1 parses");
659 assert_eq!(parsed, l);
660 assert_eq!(parsed.to_bytes(), bytes, "byte-identical round trip");
661 }
662
663 #[test]
664 fn files_empty_is_valid() {
665 let l = ledger(&[]);
666 let parsed = Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("empty files is valid");
667 assert!(parsed.files.is_empty());
668 }
669
670 #[test]
671 fn refuses_empty_file() {
672 assert!(refused(b"").contains("empty"));
673 }
674
675 #[test]
676 fn refuses_over_the_cap() {
677 let mut bytes = vec![b' '; LEDGER_MAX_BYTES + 1];
678 bytes[0] = b'{';
679 let text = refused(&bytes);
680 assert!(text.contains("8388608"), "{text}");
681 }
682
683 #[test]
684 fn refuses_invalid_utf8() {
685 let mut bytes = ledger(&[]).to_bytes();
686 bytes.insert(2, 0xff);
687 assert!(refused(&bytes).contains("UTF-8"));
688 }
689
690 #[test]
691 fn refuses_duplicate_key() {
692 let bytes = json_with((
693 "\"mode\": \"dev\"",
694 "\"mode\": \"dev\",\n \"mode\": \"dev\"",
695 ));
696 assert!(refused(&bytes).to_lowercase().contains("duplicate"));
697 }
698
699 #[test]
700 fn refuses_unknown_field() {
701 let bytes = json_with((
702 "\"schema_version\": 1",
703 "\"schema_version\": 1,\n \"extra\": 1",
704 ));
705 let text = refused(&bytes);
706 assert!(
707 text.contains("unknown field") && text.contains("extra"),
708 "{text}"
709 );
710 }
711
712 #[test]
713 fn refuses_a_missing_field() {
714 let bytes = json_with(("\"run_id\": \"run-1.a_b\",", ""));
715 assert!(refused(&bytes).contains("run_id"));
716 }
717
718 #[test]
719 fn refuses_a_wrong_field_type_without_echoing_it() {
720 let bytes = json_with((
721 "\"schema_version\": 1",
722 "\"schema_version\": \"leaky-value\"",
723 ));
724 let text = refused(&bytes);
725 assert!(!text.contains("leaky-value"), "{text}");
726 }
727
728 #[test]
729 fn refuses_a_non_object() {
730 refused(b"[]\n");
731 }
732
733 #[test]
734 fn refuses_another_schema_version() {
735 let mut l = ledger(&[]);
736 l.schema_version = 2;
737 assert!(refused_ledger(&l).contains("schema_version"));
738 }
739
740 #[test]
741 fn refuses_another_gateway_without_echoing_it() {
742 let mut l = ledger(&[]);
743 l.gateway = "other-gw".to_string();
744 let text = refused_ledger(&l);
745 assert!(text.contains("gateway"), "{text}");
746 assert!(!text.contains("other-gw"), "{text}");
747 }
748
749 #[test]
750 fn refuses_another_mode_without_echoing_it() {
751 let mut l = ledger(&[]);
752 l.mode = "prod".to_string();
753 let text = refused_ledger(&l);
754 assert!(text.contains("mode"), "{text}");
755 assert!(!text.contains("prod"), "{text}");
756 }
757
758 #[test]
759 fn refuses_a_bad_render_hash_and_run_id() {
760 let mut l = ledger(&[]);
761 l.render_sha256 = "ABC".to_string();
762 assert!(refused_ledger(&l).contains("render_sha256"));
763 let mut l = ledger(&[]);
764 l.render_sha256 = HASH.to_uppercase();
765 assert!(refused_ledger(&l).contains("render_sha256"));
766 for bad in ["", "a b", "a/b", &"x".repeat(129)] {
767 let mut l = ledger(&[]);
768 l.run_id = bad.to_string();
769 assert!(refused_ledger(&l).contains("run_id"), "{bad:?}");
770 }
771 let mut l = ledger(&[]);
772 l.run_id = "x".repeat(128);
773 Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("128 characters fit");
774 }
775
776 #[test]
777 fn refuses_unsorted_files() {
778 let l = ledger(&[
779 "ignition/database-translator/B/config.json",
780 "ignition/database-translator/A/config.json",
781 ]);
782 assert!(refused_ledger(&l).contains("sorted"));
783 }
784
785 #[test]
786 fn refuses_duplicate_files() {
787 let l = ledger(&[
788 "ignition/database-translator/A/config.json",
789 "ignition/database-translator/A/config.json",
790 ]);
791 assert!(refused_ledger(&l).contains("duplicate"));
792 }
793
794 #[test]
795 fn refuses_case_variant_duplicates() {
796 let l = ledger(&[
797 "ignition/database-translator/X/config.json",
798 "ignition/database-translator/x/config.json",
799 ]);
800 let text = refused_ledger(&l);
801 assert!(text.contains("case"), "{text}");
802 }
803
804 #[test]
805 fn refuses_config_mode_json() {
806 let l = ledger(&["config-mode.json"]);
807 assert!(refused_ledger(&l).contains("config-mode.json"));
808 let l = ledger(&["ignition/database-translator/A/config-mode.json"]);
809 assert!(refused_ledger(&l).contains("config-mode.json"));
810 }
811
812 #[test]
813 fn refuses_dot_named_components() {
814 for bad in [
815 "ignition/database-translator/.hidden/config.json",
816 "ignition/.git/config.json",
817 ".x/database-translator/A/config.json",
818 ] {
819 let text = refused_ledger(&ledger(&[bad]));
820 assert!(text.contains("dot"), "{bad}: {text}");
821 }
822 }
823
824 #[test]
825 fn refuses_fewer_than_three_components() {
826 for bad in ["ignition", "ignition/database-translator"] {
827 let text = refused_ledger(&ledger(&[bad]));
828 assert!(text.contains("three"), "{bad}: {text}");
829 }
830 }
831
832 #[test]
833 fn refuses_unsafe_paths() {
834 for bad in ["../x/y/z", "ignition/database-translator//A", "a\\b/c/d"] {
836 refused_ledger(&ledger(&[bad]));
837 }
838 }
839
840 #[test]
841 fn refuses_a_type_that_is_not_described() {
842 let l = ledger(&["acme/widget/A/config.json"]);
843 let text = refused_ledger(&l);
844 assert!(text.contains("acme/widget/A/config.json"), "{text}");
845 assert!(text.contains("type"), "{text}");
846 }
847
848 #[test]
849 fn tag_types_are_declared_resource_paths() {
850 let l = ledger(&[
851 "ignition/tag-definition/P/tags.json",
852 "ignition/tag-group/P/config.json",
853 "ignition/tag-provider/P/config.json",
854 "ignition/tag-type-definition/P/udts.json",
855 ]);
856 Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("tag types are described");
857 }
858
859 #[test]
862 fn core_key_ledger_accepts_only_routed_tag_types() {
863 let l = ledger(&[
864 ".core/ignition/tag-definition/default/Deploy/tags.json",
865 ".core/ignition/tag-type-definition/default/T/udts.json",
866 "ignition/database-translator/A/config.json",
867 ]);
868 let parsed = Ledger::parse(&loc(), &l.to_bytes(), GW, MODE).expect("core tag keys");
869 assert_eq!(parsed, l);
870 for bad in [
871 ".core/ignition/tag-provider/default/config.json",
872 ".core/ignition/database-translator/A/config.json",
873 ".core/ignition/tag-group/default/G/config.json",
874 ] {
875 let text = refused_ledger(&ledger(&[bad]));
876 assert!(text.contains("core collection"), "{bad}: {text}");
877 }
878 for bad in [
880 ".core/ignition/tag-definition/default/.x/tags.json",
881 ".core/.core/ignition/tag-definition/a/b",
882 ] {
883 refused_ledger(&ledger(&[bad]));
884 }
885 }
886
887 #[test]
891 fn core_key_ledger_case_variants_per_collection() {
892 let both = ledger(&[
893 ".core/ignition/tag-definition/P/A/tags.json",
894 "ignition/tag-definition/P/a/tags.json",
895 ]);
896 Ledger::parse(&loc(), &both.to_bytes(), GW, MODE).expect("two collections");
897 let same = ledger(&[
898 ".core/ignition/tag-definition/P/A/tags.json",
899 ".core/ignition/tag-definition/P/a/tags.json",
900 ]);
901 assert!(refused_ledger(&same).contains("case"));
902 }
903}