1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
//! The three legs of a plan seal, and the composition that binds them.
//!
//! Each leg answers one question about the world the planner saw:
//!
//! | leg | binds the plan to … |
//! |--------|------------------------------------------------------------|
//! | config | the `forjar.yaml` it was planned from |
//! | state | the lock files it READ to decide create vs update vs no-op |
//! | diff | its own body — the changes, the counters, and the selectors |
//!
//! Only the config leg shipped before (`core::config_hash`, GH-212). A plan
//! whose config hash still matched could nonetheless be stale (the lock moved
//! under it) or edited (the body is plain JSON), and both were accepted.
use crateconfig_hash;
use cratePlanSelectors;
use cratestate;
use crate;
use Path;
/// Version-tagged domain separator.
///
/// Mixed into every leg and into the composition, so a hash computed here can
/// never collide with a hash of the same bytes computed for another purpose,
/// and so a future schema change invalidates old seals instead of colliding
/// with them.
pub const SEAL_DOMAIN: &str = "forjar-plan-seal-v1";
/// Framing tag for a machine whose lock file is present.
const LOCK_PRESENT: & = b"\x01";
/// Framing tag for a machine that has no lock file yet.
///
/// Distinct from a zero-length lock: "never applied" and "applied, empty" are
/// different worlds and must not hash the same.
const LOCK_ABSENT: & = b"\x00";
/// Leg 1 — the config the plan was built from.
///
/// Delegates to the shipped canonical hash rather than re-deriving one: a
/// second expression for "the hash of this config" is exactly what GH-212 was.
/// Leg 2 — the state the planner READ.
///
/// Folds the raw bytes of every declared machine's lock file, in sorted machine
/// order, with an explicit present/absent tag and a length prefix so no two
/// different state directories can frame to the same byte stream.
///
/// Hashes the LOCK, not its `.b3` sidecar: a sidecar can be re-written by
/// `forjar reseal`, so sealing the sidecar would let a resealed tamper through.
/// Fold one machine's lock file into the state leg.
///
/// A missing file is the `LOCK_ABSENT` sentinel — that is a legitimate state,
/// not an error. Any OTHER read failure IS an error: a lock forjar cannot read
/// is a lock it cannot vouch for, and hashing "unreadable" as "absent" would
/// make a permissions change look like a fresh machine.
/// Leg 3 — the plan body itself, and the selectors it was produced under.
///
/// `ExecutionPlan`/`PlannedChange`/`PlanSelectors` hold only `String`, `Vec`,
/// `Option<String>` and `u32` — no maps — so `serde_json` emits struct fields
/// in declaration order and is already canonical. Nothing extra is needed to
/// make this reproducible.
///
/// # Refs #358 — why the selectors are in HERE rather than in a fourth leg
///
/// They answer the question this leg already asks: *was this document's body
/// edited?* `PlanSelectors` is not an input the planner read from the world —
/// it is part of what the document ASSERTS about itself, exactly like the
/// counters, and `apply --plan-file` re-plans under it. A fourth leg would need
/// its own [`super::Leg`] variant, its own remedy sentence and its own slot in
/// [`compose`] to say the same thing the `diff` leg's remedy already says.
///
/// The two are framed apart inside the hash, so a change list that happens to
/// serialise to the same bytes as a selector record cannot be swapped for one.
/// Bind the three legs and the validity window into one value.
///
/// NUL-delimited so concatenation is unambiguous: `("ab", "c")` and
/// `("a", "bc")` must not compose to the same seal.
///
/// `sealed_at` and `ttl` are INSIDE the composition, not beside it. Moving the
/// expiry of a sealed plan is therefore a hash mismatch, not a longer life.
/// A short, content-derived handle for a sealed plan.
///
/// The first 16 bytes of the seal, hex-encoded. Content-derived and not random,
/// so two seals of the same inputs at the same instant carry the same id and a
/// test never has to special-case an RNG.