release_kit/plan/
store.rs1use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::applog;
17use crate::atomic;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21
22use super::{Plan, PlanRequest, Planned};
23
24pub const PLANS_KEPT: usize = 20;
27
28pub const PLAN_FILE: &str = "plan.json";
30pub const REQUEST_FILE: &str = "request.json";
32pub const BLOBS_DIR: &str = "blobs";
34
35#[derive(Debug)]
37pub struct Stored {
38 pub plan: Plan,
40 pub request: PlanRequest,
42 pub blobs: BTreeMap<Digest, Vec<u8>>,
44 pub dir: PathBuf,
46}
47
48#[must_use]
50pub fn plans_root() -> Option<PathBuf> {
51 applog::state_root().map(|root| root.join("plans"))
52}
53
54pub fn persist(planned: &Planned, request: &PlanRequest) -> Result<PathBuf, RkError> {
62 let root = plans_root().ok_or_else(no_root)?;
63 fs::create_dir_all(&root)?;
64 restrict_dir(&root);
65 let dir = root.join(&planned.plan.identity.plan_id);
66 let staging = root.join(format!(".{}.staging", planned.plan.identity.plan_id));
67 let _ = fs::remove_dir_all(&staging);
68 fs::create_dir(&staging)?;
69 restrict_dir(&staging);
70 let blobs = staging.join(BLOBS_DIR);
71 fs::create_dir(&blobs)?;
72 restrict_dir(&blobs);
73 for (digest, bytes) in &planned.blobs {
74 let path = blobs.join(digest.to_string());
75 atomic::write(&path, bytes)?;
76 restrict_file(&path);
77 }
78 let request_json = serde_json::to_vec_pretty(request).map_err(anyhow::Error::from)?;
79 atomic::write(&staging.join(REQUEST_FILE), &request_json)?;
80 restrict_file(&staging.join(REQUEST_FILE));
81 let plan_json = serde_json::to_vec_pretty(&planned.plan).map_err(anyhow::Error::from)?;
82 atomic::write(&staging.join(PLAN_FILE), &plan_json)?;
83 restrict_file(&staging.join(PLAN_FILE));
84 if let Err(error) = fs::rename(&staging, &dir) {
88 let _ = fs::remove_dir_all(&staging);
89 if !dir.join(PLAN_FILE).is_file() {
90 return Err(RkError::Io(error));
91 }
92 }
93 let _ = prune_to(PLANS_KEPT);
94 Ok(dir)
95}
96
97pub fn load(id: &str) -> Result<Stored, RkError> {
106 if id.contains(['/', '\\']) || id == ".." || id == "." || id.is_empty() {
108 return Err(unknown(id));
109 }
110 let root = plans_root().ok_or_else(no_root)?;
111 let dir = root.join(id);
112 let plan_bytes = match fs::read(dir.join(PLAN_FILE)) {
113 Ok(bytes) => bytes,
114 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Err(unknown(id)),
115 Err(error) => return Err(RkError::Io(error)),
116 };
117 let plan: Plan = serde_json::from_slice(&plan_bytes).map_err(|error| {
118 RkError::refusal(
119 Diagnostic::new(
120 Reason::UnsupportedSchema,
121 format!("plan {id} does not read as {}: {error}", super::PLAN_SCHEMA),
122 )
123 .expected("a plan this engine stored")
124 .action("rk reconcile plan computes a fresh one"),
125 )
126 })?;
127 if plan.schema != super::PLAN_SCHEMA {
128 return Err(RkError::refusal(
129 Diagnostic::new(
130 Reason::UnsupportedSchema,
131 format!(
132 "plan {id} declares {} and this engine reads {}",
133 plan.schema,
134 super::PLAN_SCHEMA
135 ),
136 )
137 .expected("a plan computed by this engine")
138 .action("rk reconcile plan computes a fresh one"),
139 ));
140 }
141 let request: PlanRequest =
142 serde_json::from_slice(&fs::read(dir.join(REQUEST_FILE))?).map_err(anyhow::Error::from)?;
143 let mut blobs = BTreeMap::new();
144 let blobs_dir = dir.join(BLOBS_DIR);
145 if blobs_dir.is_dir() {
146 for entry in fs::read_dir(&blobs_dir)? {
147 let entry = entry?;
148 let name = entry.file_name().to_string_lossy().into_owned();
149 if let Some(digest) = Digest::parse(&name) {
150 blobs.insert(digest, fs::read(entry.path())?);
151 }
152 }
153 }
154 Ok(Stored {
155 plan,
156 request,
157 blobs,
158 dir,
159 })
160}
161
162#[must_use]
164pub fn list() -> Vec<(String, String)> {
165 let Some(root) = plans_root() else {
166 return Vec::new();
167 };
168 let Ok(entries) = fs::read_dir(root) else {
169 return Vec::new();
170 };
171 let mut plans: Vec<(String, String)> = entries
172 .filter_map(Result::ok)
173 .filter(|entry| entry.path().is_dir())
174 .filter_map(|entry| {
175 let id = entry.file_name().to_string_lossy().into_owned();
176 if id.starts_with('.') {
177 return None;
178 }
179 let bytes = fs::read(entry.path().join(PLAN_FILE)).ok()?;
180 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
181 let created = value["identity"]["created_at"].as_str()?.to_owned();
182 Some((created, id))
183 })
184 .collect();
185 plans.sort();
186 plans
187}
188
189#[must_use]
191pub fn prune_to(keep: usize) -> usize {
192 let Some(root) = plans_root() else { return 0 };
193 let plans = list();
194 let excess = plans.len().saturating_sub(keep);
195 let mut removed = 0;
196 for (_, id) in plans.into_iter().take(excess) {
197 if fs::remove_dir_all(root.join(id)).is_ok() {
198 removed += 1;
199 }
200 }
201 removed
202}
203
204fn no_root() -> RkError {
205 RkError::refusal(
206 Diagnostic::new(
207 Reason::JournalUnavailable,
208 "neither XDG_STATE_HOME nor HOME is set; the plan store has no root",
209 )
210 .expected("a state root for the plan store"),
211 )
212}
213
214fn unknown(id: &str) -> RkError {
215 RkError::missing(
216 Diagnostic::new(
217 Reason::Usage,
218 format!(
219 "no stored plan {id}: the store keeps the newest {PLANS_KEPT} plans and prunes the rest after every persist"
220 ),
221 )
222 .expected("the id rk reconcile plan printed, still within the retention window")
223 .action("rk reconcile plan computes and stores a fresh plan"),
224 )
225}
226
227fn restrict_dir(dir: &Path) {
229 #[cfg(unix)]
230 {
231 use std::os::unix::fs::PermissionsExt as _;
232 let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
233 }
234 #[cfg(not(unix))]
235 let _ = dir;
236}
237
238fn restrict_file(path: &Path) {
240 #[cfg(unix)]
241 {
242 use std::os::unix::fs::PermissionsExt as _;
243 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
244 }
245 #[cfg(not(unix))]
246 let _ = path;
247}