Skip to main content

soma_ops/
plan.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::{OperationId, OperationName, Reversibility, RiskClass, TargetRef};
7
8const MAX_PLAN_TEXT_CHARS: usize = 2_048;
9
10/// SHA-256 fingerprint of the complete authorization-relevant plan material.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(transparent)]
14pub struct PlanFingerprint(String);
15
16impl PlanFingerprint {
17    /// Parses a lowercase SHA-256 fingerprint.
18    pub fn parse(value: impl Into<String>) -> Result<Self, PlanError> {
19        let value = value.into();
20        if value.len() == 64
21            && value
22                .bytes()
23                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
24        {
25            Ok(Self(value))
26        } else {
27            Err(PlanError::InvalidFingerprint)
28        }
29    }
30
31    /// Returns the lowercase hexadecimal digest.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38/// One intended resource change described before execution.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct PlannedChange {
42    resource: TargetRef,
43    action: String,
44    summary: String,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    before_digest: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    after_digest: Option<String>,
49}
50
51impl PlannedChange {
52    /// Creates a validated planned change.
53    pub fn new(
54        resource: TargetRef,
55        action: impl Into<String>,
56        summary: impl Into<String>,
57    ) -> Result<Self, PlanError> {
58        let action = action.into();
59        let summary = summary.into();
60        validate_plan_text("change action", &action)?;
61        validate_plan_text("change summary", &summary)?;
62        Ok(Self {
63            resource,
64            action,
65            summary,
66            before_digest: None,
67            after_digest: None,
68        })
69    }
70
71    /// Records before-and-after content digests.
72    #[must_use]
73    pub fn with_digests(mut self, before: Option<String>, after: Option<String>) -> Self {
74        self.before_digest = before;
75        self.after_digest = after;
76        self
77    }
78
79    /// Returns the affected resource.
80    #[must_use]
81    pub fn resource(&self) -> &TargetRef {
82        &self.resource
83    }
84
85    /// Returns the backend-neutral action label.
86    #[must_use]
87    pub fn action(&self) -> &str {
88        &self.action
89    }
90
91    /// Returns the human-readable change summary.
92    #[must_use]
93    pub fn summary(&self) -> &str {
94        &self.summary
95    }
96}
97
98/// One ordered execution step within a plan.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101pub struct PlanStep {
102    sequence: u32,
103    operation: OperationName,
104    target: TargetRef,
105    summary: String,
106}
107
108impl PlanStep {
109    /// Creates a validated, one-based execution step.
110    pub fn new(
111        sequence: u32,
112        operation: OperationName,
113        target: TargetRef,
114        summary: impl Into<String>,
115    ) -> Result<Self, PlanError> {
116        if sequence == 0 {
117            return Err(PlanError::InvalidStepSequence);
118        }
119        let summary = summary.into();
120        validate_plan_text("step summary", &summary)?;
121        Ok(Self {
122            sequence,
123            operation,
124            target,
125            summary,
126        })
127    }
128
129    /// Returns the one-based sequence number.
130    #[must_use]
131    pub const fn sequence(&self) -> u32 {
132        self.sequence
133    }
134
135    /// Returns the operation performed by the step.
136    #[must_use]
137    pub fn operation(&self) -> &OperationName {
138        &self.operation
139    }
140
141    /// Returns the step target.
142    #[must_use]
143    pub fn target(&self) -> &TargetRef {
144        &self.target
145    }
146}
147
148/// Operation used to verify actual runtime state after execution.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151pub struct VerificationStrategy {
152    operation: OperationName,
153    description: String,
154}
155
156impl VerificationStrategy {
157    /// Creates a validated verification strategy.
158    pub fn new(
159        operation: OperationName,
160        description: impl Into<String>,
161    ) -> Result<Self, PlanError> {
162        let description = description.into();
163        validate_plan_text("verification description", &description)?;
164        Ok(Self {
165            operation,
166            description,
167        })
168    }
169
170    /// Returns the verification operation.
171    #[must_use]
172    pub fn operation(&self) -> &OperationName {
173        &self.operation
174    }
175
176    /// Returns the verification intent.
177    #[must_use]
178    pub fn description(&self) -> &str {
179        &self.description
180    }
181}
182
183/// Immutable authorization-relevant operation plan.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186pub struct OperationPlan {
187    operation_id: OperationId,
188    operation: OperationName,
189    target: TargetRef,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    topology_revision: Option<String>,
192    changes: Vec<PlannedChange>,
193    risk: RiskClass,
194    reversibility: Reversibility,
195    prerequisites: Vec<String>,
196    conflicts: Vec<String>,
197    steps: Vec<PlanStep>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    verification: Option<VerificationStrategy>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    rollback_guidance: Option<String>,
202    fingerprint: PlanFingerprint,
203}
204
205impl OperationPlan {
206    /// Creates an empty plan and computes its first fingerprint.
207    pub fn new(
208        operation_id: OperationId,
209        operation: OperationName,
210        target: TargetRef,
211        risk: RiskClass,
212        reversibility: Reversibility,
213    ) -> Result<Self, PlanError> {
214        let mut plan = Self {
215            operation_id,
216            operation,
217            target,
218            topology_revision: None,
219            changes: Vec::new(),
220            risk,
221            reversibility,
222            prerequisites: Vec::new(),
223            conflicts: Vec::new(),
224            steps: Vec::new(),
225            verification: None,
226            rollback_guidance: None,
227            fingerprint: PlanFingerprint(String::new()),
228        };
229        plan.refresh_fingerprint()?;
230        Ok(plan)
231    }
232
233    /// Binds the plan to a topology revision.
234    pub fn with_topology_revision(
235        mut self,
236        revision: impl Into<String>,
237    ) -> Result<Self, PlanError> {
238        let revision = revision.into();
239        validate_plan_text("topology revision", &revision)?;
240        self.topology_revision = Some(revision);
241        self.refresh_fingerprint()?;
242        Ok(self)
243    }
244
245    /// Adds a planned change.
246    pub fn with_change(mut self, change: PlannedChange) -> Result<Self, PlanError> {
247        self.changes.push(change);
248        self.refresh_fingerprint()?;
249        Ok(self)
250    }
251
252    /// Adds a prerequisite description.
253    pub fn with_prerequisite(mut self, prerequisite: impl Into<String>) -> Result<Self, PlanError> {
254        let prerequisite = prerequisite.into();
255        validate_plan_text("prerequisite", &prerequisite)?;
256        self.prerequisites.push(prerequisite);
257        self.refresh_fingerprint()?;
258        Ok(self)
259    }
260
261    /// Adds a conflict description.
262    pub fn with_conflict(mut self, conflict: impl Into<String>) -> Result<Self, PlanError> {
263        let conflict = conflict.into();
264        validate_plan_text("conflict", &conflict)?;
265        self.conflicts.push(conflict);
266        self.refresh_fingerprint()?;
267        Ok(self)
268    }
269
270    /// Adds an ordered execution step.
271    pub fn with_step(mut self, step: PlanStep) -> Result<Self, PlanError> {
272        self.steps.push(step);
273        validate_step_sequence(&self.steps)?;
274        self.refresh_fingerprint()?;
275        Ok(self)
276    }
277
278    /// Sets the verification strategy.
279    pub fn with_verification(
280        mut self,
281        verification: VerificationStrategy,
282    ) -> Result<Self, PlanError> {
283        self.verification = Some(verification);
284        self.refresh_fingerprint()?;
285        Ok(self)
286    }
287
288    /// Sets rollback or recovery guidance.
289    pub fn with_rollback_guidance(
290        mut self,
291        guidance: impl Into<String>,
292    ) -> Result<Self, PlanError> {
293        let guidance = guidance.into();
294        validate_plan_text("rollback guidance", &guidance)?;
295        self.rollback_guidance = Some(guidance);
296        self.refresh_fingerprint()?;
297        Ok(self)
298    }
299
300    /// Recomputes and validates the deterministic fingerprint.
301    pub fn refresh_fingerprint(&mut self) -> Result<(), PlanError> {
302        self.fingerprint = compute_fingerprint(self)?;
303        Ok(())
304    }
305
306    /// Verifies that serialized plan material still matches its fingerprint.
307    pub fn validate_fingerprint(&self) -> Result<(), PlanError> {
308        if compute_fingerprint(self)? == self.fingerprint {
309            Ok(())
310        } else {
311            Err(PlanError::FingerprintMismatch)
312        }
313    }
314
315    /// Returns the operation identity.
316    #[must_use]
317    pub fn operation_id(&self) -> &OperationId {
318        &self.operation_id
319    }
320
321    /// Returns the canonical operation name.
322    #[must_use]
323    pub fn operation(&self) -> &OperationName {
324        &self.operation
325    }
326
327    /// Returns the resolved target.
328    #[must_use]
329    pub fn target(&self) -> &TargetRef {
330        &self.target
331    }
332
333    /// Returns the topology revision when present.
334    #[must_use]
335    pub fn topology_revision(&self) -> Option<&str> {
336        self.topology_revision.as_deref()
337    }
338
339    /// Returns planned resource changes.
340    #[must_use]
341    pub fn changes(&self) -> &[PlannedChange] {
342        &self.changes
343    }
344
345    /// Returns ordered execution steps.
346    #[must_use]
347    pub fn steps(&self) -> &[PlanStep] {
348        &self.steps
349    }
350
351    /// Returns the verification strategy when present.
352    #[must_use]
353    pub fn verification(&self) -> Option<&VerificationStrategy> {
354        self.verification.as_ref()
355    }
356
357    /// Returns the authorization-relevant plan fingerprint.
358    #[must_use]
359    pub fn fingerprint(&self) -> &PlanFingerprint {
360        &self.fingerprint
361    }
362}
363
364#[derive(Serialize)]
365struct FingerprintMaterial<'a> {
366    operation_id: &'a OperationId,
367    operation: &'a OperationName,
368    target: &'a TargetRef,
369    topology_revision: &'a Option<String>,
370    changes: &'a [PlannedChange],
371    risk: RiskClass,
372    reversibility: Reversibility,
373    prerequisites: &'a [String],
374    conflicts: &'a [String],
375    steps: &'a [PlanStep],
376    verification: &'a Option<VerificationStrategy>,
377    rollback_guidance: &'a Option<String>,
378}
379
380fn compute_fingerprint(plan: &OperationPlan) -> Result<PlanFingerprint, PlanError> {
381    let material = FingerprintMaterial {
382        operation_id: &plan.operation_id,
383        operation: &plan.operation,
384        target: &plan.target,
385        topology_revision: &plan.topology_revision,
386        changes: &plan.changes,
387        risk: plan.risk,
388        reversibility: plan.reversibility,
389        prerequisites: &plan.prerequisites,
390        conflicts: &plan.conflicts,
391        steps: &plan.steps,
392        verification: &plan.verification,
393        rollback_guidance: &plan.rollback_guidance,
394    };
395    let encoded = serde_json::to_vec(&material)
396        .map_err(|error| PlanError::Serialization(error.to_string()))?;
397    let digest = Sha256::digest(encoded);
398    let value: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
399    PlanFingerprint::parse(value)
400}
401
402fn validate_step_sequence(steps: &[PlanStep]) -> Result<(), PlanError> {
403    let mut seen = BTreeSet::new();
404    for (index, step) in steps.iter().enumerate() {
405        let expected = u32::try_from(index + 1).map_err(|_| PlanError::InvalidStepSequence)?;
406        if step.sequence != expected || !seen.insert(step.sequence) {
407            return Err(PlanError::InvalidStepSequence);
408        }
409    }
410    Ok(())
411}
412
413fn validate_plan_text(field: &'static str, value: &str) -> Result<(), PlanError> {
414    let chars = value.chars().count();
415    if chars == 0 || chars > MAX_PLAN_TEXT_CHARS || value.chars().any(char::is_control) {
416        return Err(PlanError::InvalidText {
417            field,
418            max_chars: MAX_PLAN_TEXT_CHARS,
419        });
420    }
421    Ok(())
422}
423
424/// Invalid operation plan or plan fingerprint.
425#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
426#[non_exhaustive]
427pub enum PlanError {
428    /// Fingerprints must be lowercase SHA-256 hex.
429    #[error("invalid plan fingerprint")]
430    InvalidFingerprint,
431    /// Plan material no longer matches the recorded fingerprint.
432    #[error("plan fingerprint does not match plan material")]
433    FingerprintMismatch,
434    /// Execution steps must be unique, contiguous, and one-based.
435    #[error("plan steps must use contiguous one-based sequence numbers")]
436    InvalidStepSequence,
437    /// Plan text was empty, oversized, or contained control characters.
438    #[error("invalid {field}: expected 1..={max_chars} non-control characters")]
439    InvalidText {
440        /// Plan field.
441        field: &'static str,
442        /// Maximum accepted character count.
443        max_chars: usize,
444    },
445    /// Fingerprint material could not be serialized.
446    #[error("could not serialize plan fingerprint material: {0}")]
447    Serialization(String),
448}
449
450#[cfg(test)]
451#[path = "plan_tests.rs"]
452mod tests;