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//! A path that starts with
36//! [`CORE_KEY_PREFIX`](super::layout::CORE_KEY_PREFIX) (`.core/<path>`) is a
37//! file the apply wrote into the `core` collection: on a redundant pair the
38//! `tag-definition` and `tag-type-definition` units go there (quick
39//! 261102-cef, A2). Only a path of those two types may carry it, and the rules
40//! above apply to the path after it. The case-variant rule compares entries
41//! of one collection only. An older build refuses such a ledger (its dot
42//! component), so it never deletes a `core` file it cannot place.
43//!
44//! ## Location (Decision B, plan 22-12, applied by 22-13)
45//!
46//! The ledger is `ign-deploy/<gateway>/<mode>/ledger.json` ([`ledger_rel`]),
47//! and what it is relative to depends on how the node is reached
48//! ([`LedgerRoot`], [`ledger_location`]):
49//!
50//! | Node | Root | On the Windows capture host |
51//! |---|---|---|
52//! | `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/...`) |
53//! | `local` | [`LedgerRoot::DataDir`]: the node's data directory | `<data_dir>/ign-deploy/<gateway>/<mode>/ledger.json` |
54//!
55//! Why: the C6 captures showed that the service account can write its own
56//! home but nothing under the data directory, so Decision B(i) puts the
57//! ledger, the journal and the staging area there
58//! (`ign-deploy/<gateway>/<mode>/staging/<run_id>/`, [`staging_rel`]), outside
59//! every collection the gateway scans. It is never inside `config/resources/`
60//! and never dot-named. A ledger is per service account, not per gateway: a
61//! second deploy account on one gateway does not see the first one's ledger.
62//! Nothing writes a ledger before Phase 23, so nothing was migrated.
63//!
64//! ## Refusals
65//!
66//! A ledger that exists but cannot be trusted is `deploy_ledger_invalid`
67//! (exit 6); plan never falls back to an empty delete set (D-07). The problem
68//! text names fields and listed paths only, never other file content and never
69//! the foreign gateway or mode a copied ledger names.
70
71use 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
82/// File name of the ledger.
83pub const LEDGER_FILE: &str = "ledger.json";
84
85/// The largest ledger accepted (8 MiB), checked before any parse.
86pub const LEDGER_MAX_BYTES: usize = 8_388_608;
87
88/// The only schema version this build reads and writes.
89const SCHEMA_VERSION: u32 = 1;
90
91/// The longest `run_id` accepted.
92const RUN_ID_MAX: usize = 128;
93
94/// The ledger's top-level keys, in write order.
95const FIELDS: [&str; 6] = [
96    "schema_version",
97    "gateway",
98    "mode",
99    "render_sha256",
100    "run_id",
101    "files",
102];
103
104/// The declared-names ledger.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct Ledger {
108    /// Always 1.
109    pub schema_version: u32,
110    /// The gateway directory name the ledger belongs to (the target's
111    /// `gateway`).
112    pub gateway: String,
113    /// The deployment mode the ledger belongs to (the target's `mode`).
114    pub mode: String,
115    /// `render_sha256` of the render the writing apply deployed: 64
116    /// lowercase hex digits.
117    pub render_sha256: String,
118    /// Identifier of the apply run that wrote it: `[A-Za-z0-9._-]{1,128}`.
119    pub run_id: String,
120    /// The mode-collection files that apply wrote, byte-sorted and unique.
121    pub files: Vec<String>,
122}
123
124/// What the ledger location is relative to (module doc, Decision B).
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum LedgerRoot {
127    /// The node's data directory: `local` nodes.
128    DataDir,
129    /// The service account's home directory: `ssh` nodes.
130    AccountHome,
131}
132
133impl LedgerRoot {
134    /// The root for how `node` is reached: an `ssh` node keeps its ledger in
135    /// the account home, a `local` node (or one with neither) in its data
136    /// directory.
137    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
146/// The ledger path below its root: `ign-deploy/<gateway>/<mode>/ledger.json`.
147pub fn ledger_rel(gateway: &str, mode: &str) -> String {
148    format!("ign-deploy/{gateway}/{mode}/{LEDGER_FILE}")
149}
150
151/// The staging directory of one apply run below its root:
152/// `ign-deploy/<gateway>/<mode>/staging/<run_id>` (Decision B(i)).
153pub fn staging_rel(gateway: &str, mode: &str, run_id: &str) -> String {
154    format!("ign-deploy/{gateway}/{mode}/staging/{run_id}")
155}
156
157/// The on-box journal of the apply run in flight, below its root:
158/// `ign-deploy/<gateway>/<mode>/journal.json` (D-04, D-09). Never dot-named
159/// and never inside `config/resources/`; it resolves under the same
160/// [`LedgerRoot`] as the ledger.
161pub fn journal_rel(gateway: &str, mode: &str) -> String {
162    format!("ign-deploy/{gateway}/{mode}/{JOURNAL_FILE}")
163}
164
165/// The pre-apply snapshot directory of one run below its root:
166/// `ign-deploy/<gateway>/<mode>/snapshots/<run_id>` (D-04, D-07). `run_id`
167/// must have passed `RunId::parse`.
168pub fn snapshot_rel(gateway: &str, mode: &str, run_id: &str) -> String {
169    format!("ign-deploy/{gateway}/{mode}/snapshots/{run_id}")
170}
171
172/// The run record of one run below its root:
173/// `ign-deploy/<gateway>/<mode>/snapshots/<run_id>/record.json` (D-07).
174/// `run_id` must have passed `RunId::parse`.
175pub fn record_rel(gateway: &str, mode: &str, run_id: &str) -> String {
176    format!("{}/{RECORD_FILE}", snapshot_rel(gateway, mode, run_id))
177}
178
179/// File name of the per-project release record (Phase 25, V5).
180pub const RELEASE_RECORD_FILE: &str = "release.json";
181
182/// The largest release record accepted (64 KiB), checked before any parse.
183pub const RELEASE_RECORD_MAX_BYTES: usize = 65_536;
184
185/// The release record of one project below its root:
186/// `ign-deploy/<gateway>/projects/<project>/release.json` (V5).
187///
188/// It resolves under the same [`LedgerRoot`] as the ledger: the account home
189/// for an `ssh` node, the data directory for a `local` node. It is never
190/// inside `config/resources/` and never dot-named. `projects` stands where a
191/// mode name stands in the ledger path; 25-09 reserves it as a mode name so the
192/// two cannot collide.
193pub fn release_record_rel(gateway: &str, project: &str) -> String {
194    format!("ign-deploy/{gateway}/projects/{project}/{RELEASE_RECORD_FILE}")
195}
196
197/// The release record location as an error and a log name it (see
198/// [`ledger_location`]).
199pub fn release_record_location(root: LedgerRoot, gateway: &str, project: &str) -> String {
200    location_of(root, &release_record_rel(gateway, project))
201}
202
203/// The host journal of a tag-owning project run below its root (26-12):
204/// the release record's directory plus
205/// [`PROJECT_JOURNAL_FILE`](super::project_run::PROJECT_JOURNAL_FILE),
206/// `ign-deploy/<gateway>/projects/<project>/journal.json`.
207pub 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
214/// The project journal location as an error and a log name it.
215pub fn project_journal_location(root: LedgerRoot, gateway: &str, project: &str) -> String {
216    location_of(root, &project_journal_rel(gateway, project))
217}
218
219/// One file of the latest run's pre-image below its root (26-12): the
220/// release record's directory plus `pre-image/<name>`, where `name` is one of
221/// [`PreImageFile`](super::project_run::PreImageFile)'s names.
222pub 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
229/// A pre-image file location as an error and a log name it.
230pub 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
234/// The tag record of one owned provider below its root:
235/// `ign-deploy/<gateway>/tag-providers/<provider>/record.json` (26-06,
236/// TAGDEP-09).
237///
238/// It sits beside the release records, under the same [`LedgerRoot`], and is
239/// keyed by provider, not by project: the record of a provider another
240/// project deployed is found with one read and no listing (D-05). It is never
241/// inside `config/resources/` and never dot-named. `tag-providers` stands
242/// where a mode name stands in the ledger path, so the target parser reserves
243/// it as a mode name, as it does `projects`.
244pub 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
251/// The directory below `ign-deploy/<gateway>/` that holds the tag records.
252pub const TAG_RECORDS_DIR: &str = "tag-providers";
253
254/// The tag record location as an error and a log name it (see
255/// [`ledger_location`]).
256pub fn tag_record_location(root: LedgerRoot, gateway: &str, provider: &str) -> String {
257    location_of(root, &tag_record_rel(gateway, provider))
258}
259
260/// `rel` as an error and a log name it: the relative path for a
261/// [`LedgerRoot::DataDir`] node, `~/` plus the relative path for a
262/// [`LedgerRoot::AccountHome`] node.
263pub 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
270/// The ledger location as an error and a log name it: the relative path for a
271/// [`LedgerRoot::DataDir`] node, `~/` plus the relative path for a
272/// [`LedgerRoot::AccountHome`] node.
273pub fn ledger_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
274    location_of(root, &ledger_rel(gateway, mode))
275}
276
277/// The journal location as an error and a log name it (see
278/// [`ledger_location`]).
279pub fn journal_location(root: LedgerRoot, gateway: &str, mode: &str) -> String {
280    location_of(root, &journal_rel(gateway, mode))
281}
282
283/// At most 64 printable characters of `text`, for a problem message.
284fn shown(text: &str) -> String {
285    text.chars().filter(|c| !c.is_control()).take(64).collect()
286}
287
288impl Ledger {
289    /// Parse and validate `bytes` read from `location`, for the target
290    /// `gateway` and `mode`. Every refusal is
291    /// [`CoreError::DeployLedgerInvalid`] naming `location`.
292    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        // Strict UTF-8, no BOM, no repeated key, bounded depth.
312        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    /// The canonical bytes: pretty JSON in field order plus one LF. The
376    /// writer Phase 23 and the tests use; `parse(to_bytes(l)) == l`.
377    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
384/// Whether `module/ty` is a declared resource type.
385fn declared_type(module: &str, ty: &str) -> bool {
386    openapi_describes(module, ty) || (module == "ignition" && TAG_TYPES.contains(&ty))
387}
388
389/// The `files` rules (module doc); the problem text on refusal.
390fn 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    /// D-04, D-07: the journal, the snapshot and the run record sit below the
497    /// ledger's directory, under the same root, never dot-named and never
498    /// inside `config/resources/`.
499    #[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    /// 26-06 (TAGDEP-09): one tag record per gateway and provider, below the
535    /// same `ign-deploy/<gateway>/` directory as the release records, keyed by
536    /// provider so a second claimant shows with one read.
537    #[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    /// 26-12: the project journal and the pre-image sit in the release
565    /// record's directory, never under `config/resources/`, never dot-named.
566    #[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    /// Decision B(i) (22-12): ssh nodes keep the ledger, the journal and the
596    /// staging area in the service account's home; local nodes keep them in
597    /// the data directory. The relative layout is the same.
598    #[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        // the staging area is below the same directory as the ledger and is
615        // never inside config/resources or dot-named
616        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        // sorted, so the path check (not the order check) is what fires
835        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    /// quick 261102-cef (P1): a `.core/` file is a core collection path of
860    /// one of the two routed tag types, never of another type.
861    #[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        // the rest gets the same rules as a bare path
879        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    /// quick 261102-cef (P1): the case-variant rule compares one collection
888    /// with itself; the same name in `core` and the mode collection is two
889    /// files.
890    #[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}