Skip to main content

shine_core/
plan.rs

1//! Versioned, frontend-neutral security Plan contracts.
2//!
3//! A Plan is a reviewable semantic description bound to captured source and
4//! state snapshots. It is deliberately not an executable action IR and never
5//! carries file content, environment values, secret plaintext, or raw command
6//! arguments.
7
8use 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/// A reviewable capability required by a planned operation.
49///
50/// Command permissions contain only the program identity. Arguments may be
51/// derived from private inputs and therefore do not belong in this contract.
52#[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/// A stable, sorted, duplicate-free permission set.
78#[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/// Permission derivation for one complete operation.
115///
116/// Missing declarations and uncomputable requirements are blockers. Codes are
117/// stable identifiers, never arbitrary error prose.
118#[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/// A SHA-256 digest over framed snapshot observations.
193#[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/// Deterministic snapshot digest builder. Observation ordering does not affect
255/// the result, while duplicate labels fail closed.
256///
257/// Callers must supply opaque secret handles or versions, never decrypted
258/// secret plaintext.
259#[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/// Explicit frontend approval for one exact ready Plan.
433#[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}