1use crate::lifecycle::LifecycleOperation;
9use schemars::JsonSchema;
10use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
11use sha2::{Digest, Sha256};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14
15pub const PLAN_SCHEMA_VERSION: u32 = 1;
16pub const PLAN_APPROVAL_SCHEMA_VERSION: u32 = 1;
17
18const SNAPSHOT_HASH_DOMAIN: &[u8] = b"shine.snapshot.v1";
19const PLAN_HASH_DOMAIN: &[u8] = b"shine.plan.v1";
20
21#[derive(
22 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
23)]
24#[serde(rename_all = "kebab-case")]
25pub enum FilesystemAccessV1 {
26 Read,
27 Write,
28 Remove,
29 Execute,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum NetworkScopeV1 {
35 Any,
36 Host(String),
37}
38
39#[derive(
40 Clone, Copy, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
41)]
42#[serde(rename_all = "kebab-case")]
43pub enum EnvironmentSensitivityV1 {
44 Plain,
45 Secret,
46}
47
48#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)]
53#[serde(tag = "kind", rename_all = "kebab-case")]
54pub enum PermissionV1 {
55 Filesystem {
56 access: FilesystemAccessV1,
57 path: String,
58 },
59 Network {
60 scope: NetworkScopeV1,
61 },
62 Command {
63 program: String,
64 },
65 Administrator,
66 Environment {
67 name: String,
68 sensitivity: EnvironmentSensitivityV1,
69 },
70 System {
71 capability: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 resource: Option<String>,
74 },
75}
76
77#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
79#[serde(transparent)]
80pub struct PermissionSetV1(BTreeSet<PermissionV1>);
81
82impl PermissionSetV1 {
83 pub fn new(permissions: impl IntoIterator<Item = PermissionV1>) -> Self {
84 Self(permissions.into_iter().collect())
85 }
86
87 pub fn insert(&mut self, permission: PermissionV1) -> bool {
88 self.0.insert(permission)
89 }
90
91 pub fn contains(&self, permission: &PermissionV1) -> bool {
92 self.0.contains(permission)
93 }
94
95 pub fn is_empty(&self) -> bool {
96 self.0.is_empty()
97 }
98
99 pub fn iter(&self) -> impl Iterator<Item = &PermissionV1> {
100 self.0.iter()
101 }
102
103 fn difference(&self, declared: &Self) -> Self {
104 Self(self.0.difference(&declared.0).cloned().collect())
105 }
106}
107
108impl FromIterator<PermissionV1> for PermissionSetV1 {
109 fn from_iter<T: IntoIterator<Item = PermissionV1>>(iter: T) -> Self {
110 Self::new(iter)
111 }
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
119pub struct PermissionResolutionV1 {
120 pub required: PermissionSetV1,
121 pub missing_declarations: PermissionSetV1,
122 pub uncomputable_codes: BTreeSet<String>,
123}
124
125impl PermissionResolutionV1 {
126 pub fn resolve(
127 required: PermissionSetV1,
128 declared: &PermissionSetV1,
129 uncomputable_codes: impl IntoIterator<Item = impl Into<String>>,
130 ) -> Self {
131 let missing_declarations = required.difference(declared);
132 Self {
133 required,
134 missing_declarations,
135 uncomputable_codes: uncomputable_codes.into_iter().map(Into::into).collect(),
136 }
137 }
138
139 pub fn is_satisfied(&self) -> bool {
140 self.missing_declarations.is_empty() && self.uncomputable_codes.is_empty()
141 }
142}
143
144#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
145#[serde(rename_all = "kebab-case")]
146pub enum PlanActionV1 {
147 None,
148 Create,
149 Update,
150 Remove,
151 Execute,
152 Preserve,
153 Blocked,
154}
155
156#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
157pub struct PlanStepV1 {
158 pub target: String,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 pub resource: Option<String>,
161 pub action: PlanActionV1,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub diagnostic_codes: Vec<String>,
164}
165
166impl PlanStepV1 {
167 pub fn new(
168 target: impl Into<String>,
169 resource: Option<impl Into<String>>,
170 action: PlanActionV1,
171 ) -> Self {
172 Self {
173 target: target.into(),
174 resource: resource.map(Into::into),
175 action,
176 diagnostic_codes: Vec::new(),
177 }
178 }
179
180 pub fn with_diagnostic_code(mut self, code: impl Into<String>) -> Self {
181 self.diagnostic_codes.push(code.into());
182 self
183 }
184}
185
186#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
187pub struct PlanInputsV1 {
188 pub preset: SnapshotDigestV1,
189 pub state: SnapshotDigestV1,
190}
191
192#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
194pub struct SnapshotDigestV1([u8; 32]);
195
196impl SnapshotDigestV1 {
197 pub fn builder(namespace: impl AsRef<[u8]>) -> SnapshotDigestBuilderV1 {
198 SnapshotDigestBuilderV1 {
199 namespace: namespace.as_ref().to_vec(),
200 observations: BTreeMap::new(),
201 }
202 }
203
204 pub fn as_hex(&self) -> String {
205 encode_hex(&self.0)
206 }
207}
208
209impl fmt::Debug for SnapshotDigestV1 {
210 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
211 formatter
212 .debug_tuple("SnapshotDigestV1")
213 .field(&self.as_hex())
214 .finish()
215 }
216}
217
218impl Serialize for SnapshotDigestV1 {
219 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
220 where
221 S: Serializer,
222 {
223 serializer.serialize_str(&self.as_hex())
224 }
225}
226
227impl<'de> Deserialize<'de> for SnapshotDigestV1 {
228 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
229 where
230 D: Deserializer<'de>,
231 {
232 let encoded = String::deserialize(deserializer)?;
233 decode_digest(&encoded).map(Self).map_err(de::Error::custom)
234 }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub enum SnapshotDigestError {
239 DuplicateObservation(String),
240}
241
242impl fmt::Display for SnapshotDigestError {
243 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244 match self {
245 Self::DuplicateObservation(label) => {
246 write!(formatter, "duplicate snapshot observation `{label}`")
247 }
248 }
249 }
250}
251
252impl std::error::Error for SnapshotDigestError {}
253
254#[derive(Debug)]
260pub struct SnapshotDigestBuilderV1 {
261 namespace: Vec<u8>,
262 observations: BTreeMap<String, Vec<u8>>,
263}
264
265impl SnapshotDigestBuilderV1 {
266 pub fn add_observation(
267 &mut self,
268 label: impl Into<String>,
269 bytes: impl AsRef<[u8]>,
270 ) -> Result<&mut Self, SnapshotDigestError> {
271 let label = label.into();
272 if self.observations.contains_key(&label) {
273 return Err(SnapshotDigestError::DuplicateObservation(label));
274 }
275 self.observations.insert(label, bytes.as_ref().to_vec());
276 Ok(self)
277 }
278
279 pub fn finish(self) -> SnapshotDigestV1 {
280 let mut hasher = Sha256::new();
281 write_frame(&mut hasher, SNAPSHOT_HASH_DOMAIN);
282 write_frame(&mut hasher, &self.namespace);
283 write_frame(&mut hasher, &(self.observations.len() as u64).to_be_bytes());
284 for (label, bytes) in self.observations {
285 write_frame(&mut hasher, label.as_bytes());
286 write_frame(&mut hasher, &bytes);
287 }
288 SnapshotDigestV1(hasher.finalize().into())
289 }
290}
291
292#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
293#[serde(rename_all = "kebab-case")]
294pub enum PlanOperationV1 {
295 Install,
296 Update,
297 Upgrade,
298 Uninstall,
299 AppRefresh,
300 AppRecovery,
301 ShellRecovery,
302 SysRecovery,
303 AppArtifactApply,
304 AppArtifactRemove,
305 SysBootstrap,
306 SysProfileEnable,
307 SysProfileDisable,
308}
309
310impl PlanOperationV1 {
311 pub const fn as_str(self) -> &'static str {
312 match self {
313 Self::Install => "install",
314 Self::Update => "update",
315 Self::Upgrade => "upgrade",
316 Self::Uninstall => "uninstall",
317 Self::AppRefresh => "app-refresh",
318 Self::AppRecovery => "app-recovery",
319 Self::ShellRecovery => "shell-recovery",
320 Self::SysRecovery => "sys-recovery",
321 Self::AppArtifactApply => "app-artifact-apply",
322 Self::AppArtifactRemove => "app-artifact-remove",
323 Self::SysBootstrap => "sys-bootstrap",
324 Self::SysProfileEnable => "sys-profile-enable",
325 Self::SysProfileDisable => "sys-profile-disable",
326 }
327 }
328}
329
330impl From<LifecycleOperation> for PlanOperationV1 {
331 fn from(operation: LifecycleOperation) -> Self {
332 match operation {
333 LifecycleOperation::Install => Self::Install,
334 LifecycleOperation::Update => Self::Update,
335 LifecycleOperation::Upgrade => Self::Upgrade,
336 LifecycleOperation::Uninstall => Self::Uninstall,
337 }
338 }
339}
340
341#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
342pub struct PlanV1 {
343 pub schema_version: u32,
344 pub operation: PlanOperationV1,
345 pub inputs: PlanInputsV1,
346 pub steps: Vec<PlanStepV1>,
347 pub permissions: PermissionResolutionV1,
348}
349
350impl PlanV1 {
351 pub fn new(
352 operation: impl Into<PlanOperationV1>,
353 inputs: PlanInputsV1,
354 steps: Vec<PlanStepV1>,
355 required_permissions: PermissionSetV1,
356 declared_permissions: &PermissionSetV1,
357 uncomputable_permission_codes: impl IntoIterator<Item = impl Into<String>>,
358 ) -> Self {
359 Self {
360 schema_version: PLAN_SCHEMA_VERSION,
361 operation: operation.into(),
362 inputs,
363 steps,
364 permissions: PermissionResolutionV1::resolve(
365 required_permissions,
366 declared_permissions,
367 uncomputable_permission_codes,
368 ),
369 }
370 }
371
372 pub fn is_ready(&self) -> bool {
373 self.schema_version == PLAN_SCHEMA_VERSION
374 && self.permissions.is_satisfied()
375 && self
376 .steps
377 .iter()
378 .all(|step| step.action != PlanActionV1::Blocked)
379 }
380
381 pub fn fingerprint(&self) -> Result<PlanFingerprintV1, PlanApprovalError> {
382 if self.schema_version != PLAN_SCHEMA_VERSION {
383 return Err(PlanApprovalError::UnsupportedPlanSchema(
384 self.schema_version,
385 ));
386 }
387 let encoded = serde_json::to_vec(self).map_err(|_| PlanApprovalError::EncodingFailed)?;
388 let mut hasher = Sha256::new();
389 write_frame(&mut hasher, PLAN_HASH_DOMAIN);
390 write_frame(&mut hasher, &encoded);
391 Ok(PlanFingerprintV1(hasher.finalize().into()))
392 }
393}
394
395#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
396pub struct PlanFingerprintV1([u8; 32]);
397
398impl PlanFingerprintV1 {
399 pub fn as_hex(&self) -> String {
400 encode_hex(&self.0)
401 }
402}
403
404impl fmt::Debug for PlanFingerprintV1 {
405 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
406 formatter
407 .debug_tuple("PlanFingerprintV1")
408 .field(&self.as_hex())
409 .finish()
410 }
411}
412
413impl Serialize for PlanFingerprintV1 {
414 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
415 where
416 S: Serializer,
417 {
418 serializer.serialize_str(&self.as_hex())
419 }
420}
421
422impl<'de> Deserialize<'de> for PlanFingerprintV1 {
423 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
424 where
425 D: Deserializer<'de>,
426 {
427 let encoded = String::deserialize(deserializer)?;
428 decode_digest(&encoded).map(Self).map_err(de::Error::custom)
429 }
430}
431
432#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
434pub struct PlanApprovalV1 {
435 pub schema_version: u32,
436 pub plan_fingerprint: PlanFingerprintV1,
437 pub approved_permissions: PermissionSetV1,
438}
439
440impl PlanApprovalV1 {
441 pub fn for_reviewed_plan(plan: &PlanV1) -> Result<Self, PlanApprovalError> {
442 if !plan.is_ready() {
443 return Err(if plan.schema_version != PLAN_SCHEMA_VERSION {
444 PlanApprovalError::UnsupportedPlanSchema(plan.schema_version)
445 } else {
446 PlanApprovalError::PlanNotReady
447 });
448 }
449 Ok(Self {
450 schema_version: PLAN_APPROVAL_SCHEMA_VERSION,
451 plan_fingerprint: plan.fingerprint()?,
452 approved_permissions: plan.permissions.required.clone(),
453 })
454 }
455
456 pub fn validate(&self, plan: &PlanV1) -> Result<(), PlanApprovalError> {
457 if self.schema_version != PLAN_APPROVAL_SCHEMA_VERSION {
458 return Err(PlanApprovalError::UnsupportedApprovalSchema(
459 self.schema_version,
460 ));
461 }
462 if !plan.is_ready() {
463 return Err(if plan.schema_version != PLAN_SCHEMA_VERSION {
464 PlanApprovalError::UnsupportedPlanSchema(plan.schema_version)
465 } else {
466 PlanApprovalError::PlanNotReady
467 });
468 }
469 if self.approved_permissions != plan.permissions.required {
470 return Err(PlanApprovalError::PermissionSetChanged);
471 }
472 if self.plan_fingerprint != plan.fingerprint()? {
473 return Err(PlanApprovalError::PlanChanged);
474 }
475 Ok(())
476 }
477}
478
479#[derive(Clone, Copy, Debug, Eq, PartialEq)]
480pub enum PlanApprovalError {
481 PlanNotReady,
482 UnsupportedPlanSchema(u32),
483 UnsupportedApprovalSchema(u32),
484 PermissionSetChanged,
485 PlanChanged,
486 EncodingFailed,
487}
488
489impl fmt::Display for PlanApprovalError {
490 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
491 match self {
492 Self::PlanNotReady => write!(formatter, "the Plan is blocked and cannot be approved"),
493 Self::UnsupportedPlanSchema(version) => {
494 write!(formatter, "unsupported Plan schema version {version}")
495 }
496 Self::UnsupportedApprovalSchema(version) => {
497 write!(
498 formatter,
499 "unsupported Plan approval schema version {version}"
500 )
501 }
502 Self::PermissionSetChanged => {
503 write!(formatter, "the Plan permission set changed after approval")
504 }
505 Self::PlanChanged => write!(formatter, "the Plan changed after approval"),
506 Self::EncodingFailed => write!(formatter, "the Plan could not be fingerprinted"),
507 }
508 }
509}
510
511impl std::error::Error for PlanApprovalError {}
512
513fn write_frame(hasher: &mut Sha256, bytes: &[u8]) {
514 hasher.update((bytes.len() as u64).to_be_bytes());
515 hasher.update(bytes);
516}
517
518fn encode_hex(bytes: &[u8; 32]) -> String {
519 const HEX: &[u8; 16] = b"0123456789abcdef";
520 let mut encoded = String::with_capacity(64);
521 for byte in bytes {
522 encoded.push(HEX[(byte >> 4) as usize] as char);
523 encoded.push(HEX[(byte & 0x0f) as usize] as char);
524 }
525 encoded
526}
527
528fn decode_digest(encoded: &str) -> Result<[u8; 32], &'static str> {
529 if encoded.len() != 64 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
530 return Err("expected a 64-character SHA-256 digest");
531 }
532 if encoded.bytes().any(|byte| byte.is_ascii_uppercase()) {
533 return Err("SHA-256 digest must use lowercase hexadecimal");
534 }
535 let mut decoded = [0_u8; 32];
536 for (index, pair) in encoded.as_bytes().chunks_exact(2).enumerate() {
537 let high = hex_value(pair[0]);
538 let low = hex_value(pair[1]);
539 decoded[index] = (high << 4) | low;
540 }
541 Ok(decoded)
542}
543
544fn hex_value(byte: u8) -> u8 {
545 match byte {
546 b'0'..=b'9' => byte - b'0',
547 b'a'..=b'f' => byte - b'a' + 10,
548 _ => unreachable!("digest was validated before decoding"),
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 fn digest(namespace: &str, label: &str, bytes: &[u8]) -> SnapshotDigestV1 {
557 let mut builder = SnapshotDigestV1::builder(namespace);
558 builder.add_observation(label, bytes).unwrap();
559 builder.finish()
560 }
561
562 fn filesystem_permission(path: &str) -> PermissionV1 {
563 PermissionV1::Filesystem {
564 access: FilesystemAccessV1::Write,
565 path: path.to_string(),
566 }
567 }
568
569 fn ready_plan() -> PlanV1 {
570 let required = PermissionSetV1::new([
571 filesystem_permission("~/.config/demo/config.toml"),
572 PermissionV1::Environment {
573 name: "DEMO_TOKEN".to_string(),
574 sensitivity: EnvironmentSensitivityV1::Secret,
575 },
576 ]);
577 PlanV1::new(
578 LifecycleOperation::Install,
579 PlanInputsV1 {
580 preset: digest("preset", "app/demo/shine.toml", b"preset-content"),
581 state: digest("state", "app-manifest.toml", b"state-content"),
582 },
583 vec![PlanStepV1::new(
584 "app/demo",
585 Some("config.toml"),
586 PlanActionV1::Create,
587 )],
588 required.clone(),
589 &required,
590 std::iter::empty::<String>(),
591 )
592 }
593
594 #[test]
595 fn permission_sets_are_sorted_deduplicated_and_spellings_are_stable() {
596 let write = filesystem_permission("~/.config/demo/config.toml");
597 let permissions = PermissionSetV1::new([
598 PermissionV1::Network {
599 scope: NetworkScopeV1::Any,
600 },
601 write.clone(),
602 write,
603 PermissionV1::Administrator,
604 ]);
605
606 assert_eq!(permissions.iter().count(), 3);
607 let encoded = serde_json::to_string(&permissions).unwrap();
608 assert!(encoded.contains("\"kind\":\"filesystem\""));
609 assert!(encoded.contains("\"access\":\"write\""));
610 assert!(encoded.contains("\"kind\":\"network\""));
611 assert!(encoded.contains("\"kind\":\"administrator\""));
612 }
613
614 #[test]
615 fn missing_and_uncomputable_permissions_fail_closed() {
616 let required = PermissionSetV1::new([filesystem_permission("~/.config/demo")]);
617 let resolution = PermissionResolutionV1::resolve(
618 required.clone(),
619 &PermissionSetV1::default(),
620 ["permission_command_uncomputable"],
621 );
622
623 assert_eq!(resolution.missing_declarations, required);
624 assert_eq!(
625 resolution.uncomputable_codes,
626 BTreeSet::from(["permission_command_uncomputable".to_string()])
627 );
628 assert!(!resolution.is_satisfied());
629 }
630
631 #[test]
632 fn blocked_or_unresolved_plans_cannot_be_approved() {
633 let mut blocked = ready_plan();
634 blocked.steps[0].action = PlanActionV1::Blocked;
635 assert_eq!(
636 PlanApprovalV1::for_reviewed_plan(&blocked),
637 Err(PlanApprovalError::PlanNotReady)
638 );
639
640 let mut unresolved = ready_plan();
641 unresolved
642 .permissions
643 .uncomputable_codes
644 .insert("permission_network_uncomputable".to_string());
645 assert_eq!(
646 PlanApprovalV1::for_reviewed_plan(&unresolved),
647 Err(PlanApprovalError::PlanNotReady)
648 );
649 }
650
651 #[test]
652 fn approval_binds_inputs_steps_and_exact_permissions() {
653 let plan = ready_plan();
654 let approval = PlanApprovalV1::for_reviewed_plan(&plan).unwrap();
655 assert_eq!(approval.validate(&plan), Ok(()));
656
657 let mut changed_preset = plan.clone();
658 changed_preset.inputs.preset = digest("preset", "app/demo/shine.toml", b"changed");
659 assert_eq!(
660 approval.validate(&changed_preset),
661 Err(PlanApprovalError::PlanChanged)
662 );
663
664 let mut changed_state = plan.clone();
665 changed_state.inputs.state = digest("state", "app-manifest.toml", b"changed");
666 assert_eq!(
667 approval.validate(&changed_state),
668 Err(PlanApprovalError::PlanChanged)
669 );
670
671 let mut changed_step = plan.clone();
672 changed_step.steps[0].action = PlanActionV1::Update;
673 assert_eq!(
674 approval.validate(&changed_step),
675 Err(PlanApprovalError::PlanChanged)
676 );
677
678 let mut expanded = plan.clone();
679 expanded.permissions.required.insert(PermissionV1::Command {
680 program: "demo-helper".to_string(),
681 });
682 assert_eq!(
683 approval.validate(&expanded),
684 Err(PlanApprovalError::PermissionSetChanged)
685 );
686 }
687
688 #[test]
689 fn plan_and_approval_serialization_are_versioned_and_safe() {
690 let plan = ready_plan();
691 let approval = PlanApprovalV1::for_reviewed_plan(&plan).unwrap();
692 let plan_json = serde_json::to_string(&plan).unwrap();
693 let approval_toml = toml::to_string(&approval).unwrap();
694
695 assert!(plan_json.contains("\"schema_version\":1"));
696 assert!(plan_json.contains("\"operation\":\"install\""));
697 assert!(plan_json.contains("\"action\":\"create\""));
698 assert!(approval_toml.contains("schema_version = 1"));
699 assert!(approval_toml.contains("plan_fingerprint = \""));
700 for private in [
701 "preset-content",
702 "state-content",
703 "secret-plaintext",
704 "--token",
705 "/private/source/checkout",
706 ] {
707 assert!(!plan_json.contains(private));
708 assert!(!approval_toml.contains(private));
709 }
710 }
711
712 #[test]
713 fn specialized_operation_spelling_is_stable() {
714 for (operation, spelling) in [
715 (PlanOperationV1::AppRefresh, "app-refresh"),
716 (PlanOperationV1::AppRecovery, "app-recovery"),
717 (PlanOperationV1::ShellRecovery, "shell-recovery"),
718 (PlanOperationV1::SysRecovery, "sys-recovery"),
719 (PlanOperationV1::AppArtifactApply, "app-artifact-apply"),
720 (PlanOperationV1::AppArtifactRemove, "app-artifact-remove"),
721 (PlanOperationV1::SysBootstrap, "sys-bootstrap"),
722 (PlanOperationV1::SysProfileEnable, "sys-profile-enable"),
723 (PlanOperationV1::SysProfileDisable, "sys-profile-disable"),
724 ] {
725 let plan = PlanV1::new(
726 operation,
727 PlanInputsV1 {
728 preset: digest("preset", "sys/demo/shine.toml", b"preset"),
729 state: digest("state", "sys/demo", b"state"),
730 },
731 Vec::new(),
732 PermissionSetV1::default(),
733 &PermissionSetV1::default(),
734 std::iter::empty::<String>(),
735 );
736 assert!(
737 serde_json::to_string(&plan)
738 .unwrap()
739 .contains(&format!("\"operation\":\"{spelling}\""))
740 );
741 }
742 }
743
744 #[test]
745 fn snapshot_digest_is_order_independent_and_rejects_duplicate_labels() {
746 let mut first = SnapshotDigestV1::builder("state");
747 first.add_observation("b", b"two").unwrap();
748 first.add_observation("a", b"one").unwrap();
749
750 let mut second = SnapshotDigestV1::builder("state");
751 second.add_observation("a", b"one").unwrap();
752 second.add_observation("b", b"two").unwrap();
753
754 assert_eq!(first.finish(), second.finish());
755
756 let mut duplicate = SnapshotDigestV1::builder("state");
757 duplicate.add_observation("manifest", b"one").unwrap();
758 assert_eq!(
759 duplicate.add_observation("manifest", b"two").unwrap_err(),
760 SnapshotDigestError::DuplicateObservation("manifest".to_string())
761 );
762 }
763
764 #[test]
765 fn digest_deserialization_rejects_noncanonical_values() {
766 let digest = digest("state", "manifest", b"content");
767 let encoded = serde_json::to_string(&digest).unwrap();
768 assert_eq!(
769 serde_json::from_str::<SnapshotDigestV1>(&encoded).unwrap(),
770 digest
771 );
772 assert!(serde_json::from_str::<SnapshotDigestV1>("\"ABC\"").is_err());
773 }
774}