Skip to main content

shipshape_core/release/
plan_store.rs

1//! Immutable, content-addressed storage for approved release plans (ADR-0003).
2//!
3//! Plan documents live beside release journals under `ossctl/plans`. The document
4//! retains both the public plan and the exact canonical seal pre-image, allowing a
5//! later cut or resume to authenticate it without consulting a changed worktree.
6
7use std::fs;
8use std::io;
9
10use serde::Serialize;
11use serde_json::Value;
12
13use crate::contract::schema::Contract;
14use crate::contract::schema::{Adapter, ChangelogMode, ChangelogSource, Ecosystem, Registry};
15use crate::protocol::plan::{
16    BumpLevel, BumpPlan, ChangelogFinalizePlan, PinRewrite, PlanPhase, PlanTarget, ReleasePlan,
17};
18use crate::release::journal::JournalPaths;
19use crate::release::plan::{seal_bytes, seal_id_from_bytes};
20
21/// A plan-store failure. Corruption has a stable discriminator so CLI callers never
22/// mistake a damaged local approval artifact for a missing legacy plan.
23#[derive(Debug)]
24pub enum PlanStoreError {
25    /// Filesystem access failed.
26    Io(io::Error),
27    /// A stored document fails its content-address integrity check.
28    Corrupt {
29        /// Address requested by the caller.
30        plan_id: String,
31        /// Specific malformed or mismatching field.
32        detail: String,
33    },
34    /// An existing address contains bytes different from a retry's document.
35    ContentAddressViolation {
36        /// Address whose immutable content was contradicted.
37        plan_id: String,
38    },
39}
40
41impl std::fmt::Display for PlanStoreError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::Io(e) => write!(f, "{e}"),
45            Self::Corrupt { plan_id, detail } => {
46                write!(f, "plan_store_corrupt: {plan_id}: {detail}")
47            }
48            Self::ContentAddressViolation { plan_id } => write!(
49                f,
50                "plan store already contains different content for {plan_id}"
51            ),
52        }
53    }
54}
55impl std::error::Error for PlanStoreError {}
56impl From<io::Error> for PlanStoreError {
57    fn from(value: io::Error) -> Self {
58        Self::Io(value)
59    }
60}
61
62/// Result of discarding a sealed plan from the durable store.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum DiscardOutcome {
65    /// The authenticated plan document was removed.
66    Discarded,
67    /// A durable disposal marker proves an earlier request removed the plan.
68    AlreadyDiscarded,
69    /// Neither a plan nor a disposal marker has ever existed at this address.
70    Unknown,
71}
72
73#[derive(Serialize)]
74struct StoredPlan<'a> {
75    plan: &'a ReleasePlan,
76    seal_preimage: String,
77}
78
79/// Persist and authenticate sealed plans at paths derived from [`JournalPaths`].
80pub struct PlanStore {
81    paths: JournalPaths,
82}
83impl PlanStore {
84    /// Create a store rooted beside `paths`' release-journal root.
85    #[must_use]
86    pub fn new(paths: JournalPaths) -> Self {
87        Self { paths }
88    }
89
90    /// Create a document if absent. A same-byte retry is a no-op; any other
91    /// content under the same address is an integrity violation.
92    pub fn save(&self, plan: &ReleasePlan, contract: &Contract) -> Result<(), PlanStoreError> {
93        let preimage = seal_bytes(
94            contract,
95            &plan.targets,
96            &plan.head_sha,
97            &plan.version,
98            &plan.phases,
99            plan.bump.as_ref(),
100        );
101        let bytes = serde_json::to_vec(&StoredPlan {
102            plan,
103            seal_preimage: String::from_utf8(preimage).expect("canonical JSON is UTF-8"),
104        })
105        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
106        let path = self.paths.plan_file(&plan.plan_id);
107        match fs::read(&path) {
108            Ok(existing) if existing == bytes => {
109                self.clear_discard_marker(&plan.plan_id)?;
110                return Ok(());
111            }
112            Ok(_) => {
113                return Err(PlanStoreError::ContentAddressViolation {
114                    plan_id: plan.plan_id.clone(),
115                })
116            }
117            Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e.into()),
118            Err(_) => {}
119        }
120        fs::create_dir_all(self.paths.plans_dir())?;
121        let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
122        fs::write(&tmp, &bytes)?;
123        // Do not replace a concurrent writer: inspect again immediately before rename.
124        match fs::hard_link(&tmp, &path) {
125            Ok(()) => {
126                fs::remove_file(tmp)?;
127                self.clear_discard_marker(&plan.plan_id)?;
128                Ok(())
129            }
130            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
131                fs::remove_file(tmp)?;
132                if fs::read(&path)? == bytes {
133                    self.clear_discard_marker(&plan.plan_id)?;
134                    Ok(())
135                } else {
136                    Err(PlanStoreError::ContentAddressViolation {
137                        plan_id: plan.plan_id.clone(),
138                    })
139                }
140            }
141            Err(e) => {
142                let _ = fs::remove_file(tmp);
143                Err(e.into())
144            }
145        }
146    }
147
148    /// Load and authenticate a plan. Missing plans are the compatibility path for
149    /// plans made by older binaries or on another machine.
150    pub fn load(&self, plan_id: &str) -> Result<Option<ReleasePlan>, PlanStoreError> {
151        let path = self.paths.plan_file(plan_id);
152        let bytes = match fs::read(path) {
153            Ok(b) => b,
154            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
155            Err(e) => return Err(e.into()),
156        };
157        let doc: Value =
158            serde_json::from_slice(&bytes).map_err(|e| corrupt(plan_id, e.to_string()))?;
159        let preimage = doc
160            .get("seal_preimage")
161            .and_then(Value::as_str)
162            .ok_or_else(|| corrupt(plan_id, "missing seal_preimage"))?;
163        if seal_id_from_bytes(preimage.as_bytes()) != plan_id {
164            return Err(corrupt(plan_id, "seal hash does not match filename"));
165        }
166        let plan = decode_plan(
167            doc.get("plan")
168                .ok_or_else(|| corrupt(plan_id, "missing plan"))?,
169            plan_id,
170        )?;
171        if plan.plan_id != plan_id {
172            return Err(corrupt(plan_id, "plan_id does not match filename"));
173        }
174        Ok(Some(plan))
175    }
176
177    /// Authenticate and remove a sealed plan document.
178    ///
179    /// A durable marker distinguishes an idempotent retry from a well-formed but
180    /// genuinely unknown address. A present document is fully authenticated before
181    /// deletion, so corruption is never erased under the guise of disposal. Callers
182    /// coordinating this with release-run creation must hold the repository's
183    /// single-active-cut lock.
184    pub fn discard(&self, plan_id: &str) -> Result<DiscardOutcome, PlanStoreError> {
185        if !is_plan_id(plan_id) {
186            return Err(PlanStoreError::Io(io::Error::new(
187                io::ErrorKind::InvalidInput,
188                format!(
189                    "invalid plan id {plan_id:?}: expected 64 lowercase hexadecimal characters"
190                ),
191            )));
192        }
193        if self.load(plan_id)?.is_none() {
194            return Ok(if self.paths.discarded_plan_file(plan_id).is_file() {
195                DiscardOutcome::AlreadyDiscarded
196            } else {
197                DiscardOutcome::Unknown
198            });
199        }
200
201        self.write_discard_marker(plan_id)?;
202        let path = self.paths.plan_file(plan_id);
203        match fs::remove_file(&path) {
204            Ok(()) => {
205                sync_dir(&self.paths.plans_dir())?;
206                Ok(DiscardOutcome::Discarded)
207            }
208            // A concurrent idempotent retry may have won after our authenticated
209            // load. Under the release lock this is not expected, but remains safe.
210            Err(error) if error.kind() == io::ErrorKind::NotFound => {
211                Ok(DiscardOutcome::AlreadyDiscarded)
212            }
213            Err(error) => Err(error.into()),
214        }
215    }
216
217    fn write_discard_marker(&self, plan_id: &str) -> Result<(), PlanStoreError> {
218        let marker = self.paths.discarded_plan_file(plan_id);
219        let parent = marker.parent().expect("discard marker has a parent");
220        fs::create_dir_all(parent)?;
221        match fs::OpenOptions::new()
222            .write(true)
223            .create_new(true)
224            .open(&marker)
225        {
226            Ok(file) => file.sync_all()?,
227            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
228            Err(error) => return Err(error.into()),
229        }
230        sync_dir(parent)?;
231        Ok(())
232    }
233
234    fn clear_discard_marker(&self, plan_id: &str) -> Result<(), PlanStoreError> {
235        let marker = self.paths.discarded_plan_file(plan_id);
236        match fs::remove_file(&marker) {
237            Ok(()) => sync_dir(marker.parent().expect("discard marker has a parent"))?,
238            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
239            Err(error) => return Err(error.into()),
240        }
241        Ok(())
242    }
243}
244
245fn sync_dir(path: &std::path::Path) -> io::Result<()> {
246    fs::File::open(path)?.sync_all()
247}
248
249/// Whether `value` is a canonical SHA-256 plan address.
250#[must_use]
251pub fn is_plan_id(value: &str) -> bool {
252    value.len() == 64
253        && value
254            .bytes()
255            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
256}
257
258fn corrupt(id: &str, detail: impl Into<String>) -> PlanStoreError {
259    PlanStoreError::Corrupt {
260        plan_id: id.to_string(),
261        detail: detail.into(),
262    }
263}
264fn decode_changelog_plan(
265    value: Option<&Value>,
266    id: &str,
267) -> Result<Option<ChangelogFinalizePlan>, PlanStoreError> {
268    let Some(changelog) = value.filter(|value| !value.is_null()) else {
269        return Ok(None);
270    };
271    Ok(Some(ChangelogFinalizePlan {
272        mode: ChangelogMode::parse(str_at(changelog, "mode", id)?)
273            .ok_or_else(|| corrupt(id, "invalid changelog mode"))?,
274        source: ChangelogSource::parse(str_at(changelog, "source", id)?)
275            .ok_or_else(|| corrupt(id, "invalid changelog source"))?,
276        fragment_dir: str_at(changelog, "fragment_dir", id)?.into(),
277        issuectl_range: changelog
278            .get("issuectl_range")
279            .and_then(Value::as_str)
280            .map(str::to_string),
281    }))
282}
283
284fn bool_at_or_false(v: &Value, key: &str, id: &str) -> Result<bool, PlanStoreError> {
285    match v.get(key) {
286        None => Ok(false),
287        Some(Value::Bool(value)) => Ok(*value),
288        Some(_) => Err(corrupt(id, format!("invalid {key}: expected a boolean"))),
289    }
290}
291fn str_at<'a>(v: &'a Value, key: &str, id: &str) -> Result<&'a str, PlanStoreError> {
292    v.get(key)
293        .and_then(Value::as_str)
294        .ok_or_else(|| corrupt(id, format!("missing or invalid {key}")))
295}
296fn decode_phase(value: &Value, id: &str) -> Result<PlanPhase, PlanStoreError> {
297    match value.as_str() {
298        Some("bump") => Ok(PlanPhase::Bump),
299        Some("dry-run-all") => Ok(PlanPhase::DryRunAll),
300        Some("build-all") => Ok(PlanPhase::BuildAll),
301        Some("publish-all") => Ok(PlanPhase::PublishAll),
302        Some("tag") => Ok(PlanPhase::Tag),
303        Some("dist") => Ok(PlanPhase::Dist),
304        Some("verify") => Ok(PlanPhase::Verify),
305        Some("advance-branch") => Ok(PlanPhase::AdvanceBranch),
306        _ => Err(corrupt(id, "invalid phase")),
307    }
308}
309
310fn decode_plan(v: &Value, id: &str) -> Result<ReleasePlan, PlanStoreError> {
311    let targets = v
312        .get("targets")
313        .and_then(Value::as_array)
314        .ok_or_else(|| corrupt(id, "invalid targets"))?
315        .iter()
316        .map(|t| {
317            Ok(PlanTarget {
318                ecosystem: Ecosystem::parse(str_at(t, "ecosystem", id)?)
319                    .ok_or_else(|| corrupt(id, "invalid ecosystem"))?,
320                package: t.get("package").and_then(Value::as_str).map(str::to_string),
321                registry: Registry::parse(str_at(t, "registry", id)?)
322                    .ok_or_else(|| corrupt(id, "invalid registry"))?,
323                adapter: Adapter::parse(str_at(t, "adapter", id)?)
324                    .ok_or_else(|| corrupt(id, "invalid adapter"))?,
325            })
326        })
327        .collect::<Result<Vec<_>, PlanStoreError>>()?;
328    let phases = v
329        .get("phases")
330        .and_then(Value::as_array)
331        .ok_or_else(|| corrupt(id, "invalid phases"))?
332        .iter()
333        .map(|phase| decode_phase(phase, id))
334        .collect::<Result<Vec<_>, _>>()?;
335    let bump = match v.get("bump") {
336        None | Some(Value::Null) => None,
337        Some(b) => Some(BumpPlan {
338            level: BumpLevel::parse(str_at(b, "level", id)?)
339                .ok_or_else(|| corrupt(id, "invalid bump level"))?,
340            from_version: str_at(b, "from_version", id)?.into(),
341            to_version: str_at(b, "to_version", id)?.into(),
342            pin_rewrites: b
343                .get("pin_rewrites")
344                .and_then(Value::as_array)
345                .ok_or_else(|| corrupt(id, "invalid pin_rewrites"))?
346                .iter()
347                .map(|p| {
348                    Ok(PinRewrite {
349                        in_package: str_at(p, "in_package", id)?.into(),
350                        workspace_root: bool_at_or_false(p, "workspace_root", id)?,
351                        dependency: str_at(p, "dependency", id)?.into(),
352                        from: str_at(p, "from", id)?.into(),
353                        to: str_at(p, "to", id)?.into(),
354                    })
355                })
356                .collect::<Result<Vec<_>, PlanStoreError>>()?,
357            changelog_finalize: b
358                .get("changelog_finalize")
359                .and_then(Value::as_bool)
360                .ok_or_else(|| corrupt(id, "invalid changelog_finalize"))?,
361            changelog: decode_changelog_plan(b.get("changelog"), id)?,
362            bump_hook: b
363                .get("bump_hook")
364                .and_then(Value::as_str)
365                .map(str::to_string),
366        }),
367    };
368    Ok(ReleasePlan {
369        plan_id: str_at(v, "plan_id", id)?.into(),
370        contract_schema_version: u32::try_from(
371            v.get("contract_schema_version")
372                .and_then(Value::as_u64)
373                .ok_or_else(|| corrupt(id, "invalid contract_schema_version"))?,
374        )
375        .map_err(|_| corrupt(id, "contract_schema_version exceeds u32"))?,
376        head_sha: str_at(v, "head_sha", id)?.into(),
377        version: str_at(v, "version", id)?.into(),
378        targets,
379        phases,
380        bump,
381        homebrew_tap: v
382            .get("homebrew_tap")
383            .and_then(Value::as_str)
384            .map(str::to_string),
385        license: v.get("license").and_then(Value::as_str).map(str::to_string),
386        description: v
387            .get("description")
388            .and_then(Value::as_str)
389            .map(str::to_string),
390        homebrew_platforms: v
391            .get("homebrew_platforms")
392            .and_then(Value::as_array)
393            .ok_or_else(|| corrupt(id, "invalid homebrew_platforms"))?
394            .iter()
395            .map(|x| {
396                x.as_str()
397                    .map(str::to_string)
398                    .ok_or_else(|| corrupt(id, "invalid platform"))
399            })
400            .collect::<Result<Vec<_>, _>>()?,
401    })
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn legacy_v5_plan_without_verify_remains_readable() {
410        // Existing v5 plans must load so an interrupted run can resume through the
411        // now-mandatory verify barrier. A fresh cut re-derives a v8 address and
412        // rejects this old approval as stale instead of silently extending it.
413        let plan = serde_json::json!({
414            "plan_id": "legacy",
415            "contract_schema_version": 1,
416            "head_sha": "abc",
417            "version": "1.0.0",
418            "targets": [],
419            "phases": ["dry-run-all", "build-all", "publish-all", "tag", "dist"],
420            "homebrew_tap": null,
421            "license": null,
422            "description": null,
423            "homebrew_platforms": []
424        });
425
426        let decoded = decode_plan(&plan, "legacy").expect("legacy plan is valid");
427        assert_eq!(decoded.phases, PlanPhase::SEQUENCE[..5]);
428    }
429
430    #[test]
431    fn malformed_present_workspace_root_flag_is_corruption() {
432        let plan = serde_json::json!({
433            "plan_id": "bad",
434            "contract_schema_version": 4,
435            "head_sha": "abc",
436            "version": "0.5.0",
437            "targets": [],
438            "phases": ["bump"],
439            "bump": {
440                "level": "minor",
441                "from_version": "0.4.0",
442                "to_version": "0.5.0",
443                "pin_rewrites": [{
444                    "in_package": "workspace",
445                    "workspace_root": "true",
446                    "dependency": "core",
447                    "from": "=0.4.0",
448                    "to": "=0.5.0"
449                }],
450                "changelog_finalize": true
451            },
452            "homebrew_tap": null,
453            "license": null,
454            "description": null,
455            "homebrew_platforms": []
456        });
457        assert!(decode_plan(&plan, "bad").is_err());
458    }
459
460    #[test]
461    fn legacy_member_only_bump_plan_remains_readable_after_workspace_root_support() {
462        let plan = serde_json::json!({
463            "plan_id": "legacy-v7",
464            "contract_schema_version": 4,
465            "head_sha": "abc",
466            "version": "0.5.0",
467            "targets": [],
468            "phases": ["bump", "dry-run-all", "build-all", "publish-all", "tag", "dist", "verify"],
469            "bump": {
470                "level": "minor",
471                "from_version": "0.4.0",
472                "to_version": "0.5.0",
473                "pin_rewrites": [{
474                    "in_package": "cli",
475                    "dependency": "core",
476                    "from": "=0.4.0",
477                    "to": "=0.5.0"
478                }],
479                "changelog_finalize": true
480            },
481            "homebrew_tap": null,
482            "license": null,
483            "description": null,
484            "homebrew_platforms": []
485        });
486
487        let decoded = decode_plan(&plan, "legacy-v7").expect("v7 bump plan remains loadable");
488        let rewrite = &decoded.bump.as_ref().unwrap().pin_rewrites[0];
489        assert!(!rewrite.workspace_root);
490        assert_eq!(decoded.phases.last(), Some(&PlanPhase::Verify));
491    }
492}