Skip to main content

ignition_core/deploy/
ledger.rs

1//! The declared-names ledger, format v1 (Phase 22, D-05, D-07).
2//!
3//! The ledger is an on-box record of the mode-collection files the last
4//! apply wrote for one gateway and mode. `deploy plan` reads it to learn which
5//! names this tool previously declared: the delete set is exactly the
6//! ledger's files minus the files being rendered, so a resource the tool
7//! never wrote is never proposed for deletion. Nothing in Phase 22 writes a
8//! ledger; [`Ledger::to_bytes`] is the writer Phase 23 and the tests use.
9//!
10//! ## Format v1
11//!
12//! One JSON object, strict: unknown fields, duplicate keys and nesting beyond
13//! the shared depth cap are refused, and the file is at most
14//! [`LEDGER_MAX_BYTES`] (8 MiB), strict UTF-8, never empty.
15//!
16//! ```json
17//! {
18//!   "schema_version": 1,
19//!   "gateway": "<target gateway>",
20//!   "mode": "<target mode>",
21//!   "render_sha256": "<64 lowercase hex>",
22//!   "run_id": "<[A-Za-z0-9._-]{1,128}>",
23//!   "files": ["<module>/<type>/..."]
24//! }
25//! ```
26//!
27//! `files` holds collection-relative paths, byte-sorted and unique, with no
28//! per-file hashes (the ledger is a name list, not a drift base). Every path
29//! has at least three components, no dot-named component, is not
30//! `config-mode.json`, is not a case-variant of another entry (a
31//! case-insensitive host would treat the two as one file), and sits under a
32//! type the gateway describes (the OpenAPI resource types, or the four tag
33//! types under `ignition`).
34//!
35//! ## Location (Decision B, plan 22-12, applied by 22-13)
36//!
37//! The ledger is `ign-deploy/<gateway>/<mode>/ledger.json` ([`ledger_rel`]),
38//! and what it is relative to depends on how the node is reached
39//! ([`LedgerRoot`], [`ledger_location`]):
40//!
41//! | Node | Root | On the Windows capture host |
42//! |---|---|---|
43//! | `ssh` (Linux or Windows) | [`LedgerRoot::AccountHome`]: the service account's home directory | `C:\Users\svc-ignition-deploy\ign-deploy\<gateway>\<mode>\ledger.json` (sftp `/C:/Users/svc-ignition-deploy/ign-deploy/...`) |
44//! | `local` | [`LedgerRoot::DataDir`]: the node's data directory | `<data_dir>/ign-deploy/<gateway>/<mode>/ledger.json` |
45//!
46//! Why: the C6 captures showed that the service account can write its own
47//! home but nothing under the data directory, so Decision B(i) puts the
48//! ledger, the journal and the staging area there
49//! (`ign-deploy/<gateway>/<mode>/staging/<run_id>/`, [`staging_rel`]), outside
50//! every collection the gateway scans. It is never inside `config/resources/`
51//! and never dot-named. A ledger is per service account, not per gateway: a
52//! second deploy account on one gateway does not see the first one's ledger.
53//! Nothing writes a ledger before Phase 23, so nothing was migrated.
54//!
55//! ## Refusals
56//!
57//! A ledger that exists but cannot be trusted is `deploy_ledger_invalid`
58//! (exit 6); plan never falls back to an empty delete set (D-07). The problem
59//! text names fields and listed paths only, never other file content and never
60//! the foreign gateway or mode a copied ledger names.
61
62use 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
73/// File name of the ledger.
74pub const LEDGER_FILE: &str = "ledger.json";
75
76/// The largest ledger accepted (8 MiB), checked before any parse.
77pub const LEDGER_MAX_BYTES: usize = 8_388_608;
78
79/// The only schema version this build reads and writes.
80const SCHEMA_VERSION: u32 = 1;
81
82/// The longest `run_id` accepted.
83const RUN_ID_MAX: usize = 128;
84
85/// The ledger's top-level keys, in write order.
86const FIELDS: [&str; 6] = [
87    "schema_version",
88    "gateway",
89    "mode",
90    "render_sha256",
91    "run_id",
92    "files",
93];
94
95/// The declared-names ledger.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct Ledger {
99    /// Always 1.
100    pub schema_version: u32,
101    /// The gateway directory name the ledger belongs to (the target's
102    /// `gateway`).
103    pub gateway: String,
104    /// The deployment mode the ledger belongs to (the target's `mode`).
105    pub mode: String,
106    /// `render_sha256` of the render the writing apply deployed: 64
107    /// lowercase hex digits.
108    pub render_sha256: String,
109    /// Identifier of the apply run that wrote it: `[A-Za-z0-9._-]{1,128}`.
110    pub run_id: String,
111    /// The mode-collection files that apply wrote, byte-sorted and unique.
112    pub files: Vec<String>,
113}
114
115/// What the ledger location is relative to (module doc, Decision B).
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum LedgerRoot {
118    /// The node's data directory: `local` nodes.
119    DataDir,
120    /// The service account's home directory: `ssh` nodes.
121    AccountHome,
122}
123
124impl LedgerRoot {
125    /// The root for how `node` is reached: an `ssh` node keeps its ledger in
126    /// the account home, a `local` node (or one with neither) in its data
127    /// directory.
128    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
137/// The ledger path below its root: `ign-deploy/<gateway>/<mode>/ledger.json`.
138pub fn ledger_rel(gateway: &str, mode: &str) -> String {
139    format!("ign-deploy/{gateway}/{mode}/{LEDGER_FILE}")
140}
141
142/// The staging directory of one apply run below its root:
143/// `ign-deploy/<gateway>/<mode>/staging/<run_id>` (Decision B(i)).
144pub fn staging_rel(gateway: &str, mode: &str, run_id: &str) -> String {
145    format!("ign-deploy/{gateway}/{mode}/staging/{run_id}")
146}
147
148/// The on-box journal of the apply run in flight, below its root:
149/// `ign-deploy/<gateway>/<mode>/journal.json` (D-04, D-09). Never dot-named
150/// and never inside `config/resources/`; it resolves under the same
151/// [`LedgerRoot`] as the ledger.
152pub fn journal_rel(gateway: &str, mode: &str) -> String {
153    format!("ign-deploy/{gateway}/{mode}/{JOURNAL_FILE}")
154}
155
156/// The pre-apply snapshot directory of one run below its root:
157/// `ign-deploy/<gateway>/<mode>/snapshots/<run_id>` (D-04, D-07). `run_id`
158/// must have passed `RunId::parse`.
159pub fn snapshot_rel(gateway: &str, mode: &str, run_id: &str) -> String {
160    format!("ign-deploy/{gateway}/{mode}/snapshots/{run_id}")
161}
162
163/// The run record of one run below its root:
164/// `ign-deploy/<gateway>/<mode>/snapshots/<run_id>/record.json` (D-07).
165/// `run_id` must have passed `RunId::parse`.
166pub fn record_rel(gateway: &str, mode: &str, run_id: &str) -> String {
167    format!("{}/{RECORD_FILE}", snapshot_rel(gateway, mode, run_id))
168}
169
170/// File name of the per-project release record (Phase 25, V5).
171pub const RELEASE_RECORD_FILE: &str = "release.json";
172
173/// The largest release record accepted (64 KiB), checked before any parse.
174pub const RELEASE_RECORD_MAX_BYTES: usize = 65_536;
175
176/// The release record of one project below its root:
177/// `ign-deploy/<gateway>/projects/<project>/release.json` (V5).
178///
179/// It resolves under the same [`LedgerRoot`] as the ledger: the account home
180/// for an `ssh` node, the data directory for a `local` node. It is never
181/// inside `config/resources/` and never dot-named. `projects` stands where a
182/// mode name stands in the ledger path; 25-09 reserves it as a mode name so the
183/// two cannot collide.
184pub fn release_record_rel(gateway: &str, project: &str) -> String {
185    format!("ign-deploy/{gateway}/projects/{project}/{RELEASE_RECORD_FILE}")
186}
187
188/// The release record location as an error and a log name it (see
189/// [`ledger_location`]).
190pub fn release_record_location(root: LedgerRoot, gateway: &str, project: &str) -> String {
191    location_of(root, &release_record_rel(gateway, project))
192}
193
194/// `rel` as an error and a log name it: the relative path for a
195/// [`LedgerRoot::DataDir`] node, `~/` plus the relative path for a
196/// [`LedgerRoot::AccountHome`] node.
197pub 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
204/// The ledger location as an error and a log name it: the relative path for a
205/// [`LedgerRoot::DataDir`] node, `~/` plus the relative path for a
206/// [`LedgerRoot::AccountHome`] node.
207pub fn ledger_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
208    location_of(root, &ledger_rel(gateway, mode))
209}
210
211/// The journal location as an error and a log name it (see
212/// [`ledger_location`]).
213pub fn journal_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
214    location_of(root, &journal_rel(gateway, mode))
215}
216
217/// At most 64 printable characters of `text`, for a problem message.
218fn shown(text: &str) -> String {
219    text.chars().filter(|c| !c.is_control()).take(64).collect()
220}
221
222impl Ledger {
223    /// Parse and validate `bytes` read from `location`, for the target
224    /// `gateway` and `mode`. Every refusal is
225    /// [`CoreError::DeployLedgerInvalid`] naming `location`.
226    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        // Strict UTF-8, no BOM, no repeated key, bounded depth.
246        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    /// The canonical bytes: pretty JSON in field order plus one LF. The
310    /// writer Phase 23 and the tests use; `parse(to_bytes(l)) == l`.
311    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
318/// Whether `module/ty` is a declared resource type.
319fn declared_type(module: &str, ty: &str) -> bool {
320    openapi_describes(module, ty) || (module == "ignition" && TAG_TYPES.contains(&ty))
321}
322
323/// The `files` rules (module doc); the problem text on refusal.
324fn 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    /// D-04, D-07: the journal, the snapshot and the run record sit below the
425    /// ledger's directory, under the same root, never dot-named and never
426    /// inside `config/resources/`.
427    #[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    /// Decision B(i) (22-12): ssh nodes keep the ledger, the journal and the
463    /// staging area in the service account's home; local nodes keep them in
464    /// the data directory. The relative layout is the same.
465    #[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        // the staging area is below the same directory as the ledger and is
482        // never inside config/resources or dot-named
483        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        // sorted, so the path check (not the order check) is what fires
702        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}