1use 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#[derive(Debug)]
24pub enum PlanStoreError {
25 Io(io::Error),
27 Corrupt {
29 plan_id: String,
31 detail: String,
33 },
34 ContentAddressViolation {
36 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum DiscardOutcome {
65 Discarded,
67 AlreadyDiscarded,
69 Unknown,
71}
72
73#[derive(Serialize)]
74struct StoredPlan<'a> {
75 plan: &'a ReleasePlan,
76 seal_preimage: String,
77}
78
79pub struct PlanStore {
81 paths: JournalPaths,
82}
83impl PlanStore {
84 #[must_use]
86 pub fn new(paths: JournalPaths) -> Self {
87 Self { paths }
88 }
89
90 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 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 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 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 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#[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 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}