1use crate::{
2 Policy,
3 catalog::scan,
4 filesystem::{contained_file, hash, read_verified, replace, write_new},
5 optimizer,
6};
7use anyhow::{Context, Result, ensure};
8use serde::{Deserialize, Serialize};
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 fs,
12 io::{Read, Write},
13 path::{Path, PathBuf},
14};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Candidate {
19 pub path: PathBuf,
20 pub original_sha256: String,
21 pub optimized_sha256: String,
22 pub original_bytes: u64,
23 pub optimized_bytes: u64,
24 pub contents_path: PathBuf,
25 pub contents_sha256: String,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct Plan {
31 pub schema_version: u32,
32 pub root: PathBuf,
33 pub backend: String,
34 pub policy: Policy,
35 pub candidates: Vec<Candidate>,
36 pub skipped: BTreeMap<PathBuf, String>,
37 pub diagnostics: Vec<String>,
38}
39
40impl Plan {
41 pub fn savings_bytes(&self) -> u64 {
42 self.candidates
43 .iter()
44 .map(|item| item.original_bytes.saturating_sub(item.optimized_bytes))
45 .sum()
46 }
47}
48
49pub fn create_plan(
52 root: impl AsRef<Path>,
53 directory: impl AsRef<Path>,
54 policy: Policy,
55) -> Result<Plan> {
56 policy.validate()?;
57 let inventory = scan(root)?;
58 let directory = directory.as_ref();
59 fs::create_dir(directory).with_context(|| {
60 format!(
61 "creating new plan directory {}; it must not already exist",
62 directory.display()
63 )
64 })?;
65 fs::create_dir(directory.join("originals"))?;
66 fs::create_dir(directory.join("candidates"))?;
67 let mut plan = Plan {
68 schema_version: 1,
69 root: inventory.root,
70 backend: "oxipng/10.2.1; strict-png/1".into(),
71 policy,
72 candidates: vec![],
73 skipped: BTreeMap::new(),
74 diagnostics: inventory.diagnostics,
75 };
76 for asset in inventory.assets {
77 if let Some(reason) = asset.reason {
78 plan.skipped.insert(asset.path, reason);
79 continue;
80 }
81 let result = (|| -> Result<()> {
82 ensure!(
83 asset.bytes >= plan.policy.min_input_bytes,
84 "below_input_threshold"
85 );
86 let path = contained_file(&plan.root, &asset.path)?;
87 let original = read_bounded(&path)?;
88 let candidate = optimizer::optimize(&original, &plan.policy)?;
89 ensure!(candidate.len() < original.len(), "not_smaller");
90 let saving = (original.len() - candidate.len()) as u64;
91 ensure!(
92 saving >= plan.policy.min_savings_bytes
93 && saving as f64 * 100.0 / original.len() as f64
94 >= plan.policy.min_savings_percent,
95 "below_savings_threshold"
96 );
97 read_verified(&path, &hash(&original))?;
99 read_verified(
100 &contained_file(&plan.root, &asset.contents_path)?,
101 &asset.contents_sha256,
102 )?;
103 let original_hash = hash(&original);
104 let candidate_hash = hash(&candidate);
105 save_blob(directory, "originals", &original_hash, &original)?;
106 save_blob(directory, "candidates", &candidate_hash, &candidate)?;
107 plan.candidates.push(Candidate {
108 path: asset.path.clone(),
109 original_sha256: original_hash,
110 optimized_sha256: candidate_hash,
111 original_bytes: original.len() as u64,
112 optimized_bytes: candidate.len() as u64,
113 contents_path: asset.contents_path,
114 contents_sha256: asset.contents_sha256,
115 });
116 Ok(())
117 })();
118 if let Err(error) = result {
119 plan.skipped.insert(asset.path, format!("{error:#}"));
120 }
121 }
122 plan.candidates.sort_by(|a, b| {
123 (b.original_bytes - b.optimized_bytes)
124 .cmp(&(a.original_bytes - a.optimized_bytes))
125 .then_with(|| a.path.cmp(&b.path))
126 });
127 write_new(
128 &directory.join("plan.json"),
129 &serde_json::to_vec_pretty(&plan)?,
130 )?;
131 Ok(plan)
132}
133
134fn save_blob(directory: &Path, folder: &str, digest: &str, bytes: &[u8]) -> Result<()> {
135 let path = directory.join(folder).join(format!("{digest}.png"));
136 if path.exists() {
137 read_verified(&path, digest)?;
138 } else {
139 write_new(&path, bytes)?;
140 }
141 Ok(())
142}
143
144fn read_bounded(path: &Path) -> Result<Vec<u8>> {
145 let mut data = Vec::new();
146 fs::File::open(path)?
147 .take(optimizer::MAX_INPUT as u64 + 1)
148 .read_to_end(&mut data)?;
149 ensure!(
150 data.len() <= optimizer::MAX_INPUT,
151 "input exceeds 64 MiB limit"
152 );
153 Ok(data)
154}
155
156pub fn read_plan(directory: impl AsRef<Path>) -> Result<Plan> {
157 let directory = fs::canonicalize(directory)?;
158 let path = contained_file(&directory, Path::new("plan.json"))?;
159 let plan: Plan = serde_json::from_slice(&read_bounded(&path)?)?;
160 ensure!(plan.schema_version == 1, "unsupported plan schema version");
161 ensure!(
162 plan.backend == "oxipng/10.2.1; strict-png/1",
163 "unsupported optimization backend"
164 );
165 plan.policy.validate()?;
166 ensure!(
167 plan.root.is_absolute() && fs::canonicalize(&plan.root)? == plan.root,
168 "project root moved or changed"
169 );
170 let mut paths = BTreeSet::new();
171 for candidate in &plan.candidates {
172 ensure!(paths.insert(&candidate.path), "duplicate candidate path");
173 for digest in [
174 &candidate.original_sha256,
175 &candidate.optimized_sha256,
176 &candidate.contents_sha256,
177 ] {
178 ensure!(
179 digest.len() == 64
180 && digest
181 .bytes()
182 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),
183 "invalid SHA-256 digest"
184 );
185 }
186 ensure!(
187 candidate.optimized_bytes < candidate.original_bytes
188 && candidate.original_bytes <= optimizer::MAX_INPUT as u64,
189 "invalid candidate sizes"
190 );
191 }
192 Ok(plan)
193}
194
195#[derive(Debug, Serialize)]
196pub struct ApplyReport {
197 pub schema_version: u32,
198 pub changed: usize,
199 pub already_current: usize,
200 pub source_bytes_saved: u64,
201}
202
203impl Default for ApplyReport {
204 fn default() -> Self {
205 Self {
206 schema_version: 1,
207 changed: 0,
208 already_current: 0,
209 source_bytes_saved: 0,
210 }
211 }
212}
213
214pub fn apply(directory: impl AsRef<Path>) -> Result<ApplyReport> {
216 execute(directory.as_ref(), false)
217}
218
219pub fn restore(directory: impl AsRef<Path>) -> Result<ApplyReport> {
221 execute(directory.as_ref(), true)
222}
223
224struct Lock(PathBuf);
225impl Drop for Lock {
226 fn drop(&mut self) {
227 let _ = fs::remove_file(&self.0);
228 }
229}
230
231fn execute(directory: &Path, restoring: bool) -> Result<ApplyReport> {
232 let directory = fs::canonicalize(directory)?;
233 let lock_path = directory.join(".lock");
234 write_new(
235 &lock_path,
236 format!("pid={}\n", std::process::id()).as_bytes(),
237 )
238 .context(
239 "plan locked; remove .lock only after confirming no resopt process is using this plan",
240 )?;
241 let _lock = Lock(lock_path);
242 let plan = read_plan(&directory)?;
243 let root_lock_path = plan.root.join(".resopt.lock");
244 write_new(&root_lock_path, format!("pid={}\n", std::process::id()).as_bytes())
245 .context("project locked; remove .resopt.lock only after confirming no resopt process is modifying this project")?;
246 let _root_lock = Lock(root_lock_path);
247 let inventory = scan(&plan.root)?;
248 let assets: BTreeMap<_, _> = inventory
249 .assets
250 .into_iter()
251 .map(|asset| (asset.path.clone(), asset))
252 .collect();
253 for candidate in &plan.candidates {
255 let asset = assets
256 .get(&candidate.path)
257 .context("candidate is no longer referenced by a supported catalog")?;
258 ensure!(
259 asset.eligible
260 && asset.contents_path == candidate.contents_path
261 && asset.contents_sha256 == candidate.contents_sha256,
262 "catalog eligibility or Contents.json changed: {}",
263 candidate.path.display()
264 );
265 verify_entry(&plan, &directory, candidate)?;
266 }
267 let journal_path = directory.join("journal.jsonl");
268 if fs::symlink_metadata(&journal_path).is_ok() {
269 contained_file(&directory, Path::new("journal.jsonl"))?;
270 }
271 let mut journal = fs::OpenOptions::new()
272 .append(true)
273 .create(true)
274 .open(journal_path)?;
275 let mut report = ApplyReport::default();
276 for candidate in &plan.candidates {
277 let (source, original, optimized, current) = verify_entry(&plan, &directory, candidate)?;
279 let (target, expected_hash) = if restoring {
280 (&original, &candidate.original_sha256)
281 } else {
282 (&optimized, &candidate.optimized_sha256)
283 };
284 if current == *expected_hash {
285 report.already_current += 1;
286 continue;
287 }
288 let operation = if restoring { "restore" } else { "apply" };
289 event(&mut journal, operation, "started", &candidate.path)?;
290 replace(&source, target).with_context(|| {
291 format!(
292 "{operation} failed; originals remain in {}; run resopt restore to recover",
293 directory.display()
294 )
295 })?;
296 read_verified(&source, expected_hash)?;
297 event(&mut journal, operation, "completed", &candidate.path)?;
298 report.changed += 1;
299 if !restoring {
300 report.source_bytes_saved += candidate.original_bytes - candidate.optimized_bytes;
301 }
302 }
303 Ok(report)
304}
305
306type VerifiedEntry = (PathBuf, Vec<u8>, Vec<u8>, String);
307
308fn verify_entry(plan: &Plan, directory: &Path, candidate: &Candidate) -> Result<VerifiedEntry> {
309 let source = contained_file(&plan.root, &candidate.path)?;
310 read_verified(
311 &contained_file(&plan.root, &candidate.contents_path)?,
312 &candidate.contents_sha256,
313 )?;
314 let original = blob(directory, "originals", &candidate.original_sha256)?;
315 let optimized = blob(directory, "candidates", &candidate.optimized_sha256)?;
316 ensure!(
317 original.len() as u64 == candidate.original_bytes
318 && optimized.len() as u64 == candidate.optimized_bytes,
319 "candidate sizes do not match blobs"
320 );
321 optimizer::verify(&original, &optimized)?;
322 let current = hash(&read_bounded(&source)?);
323 ensure!(
324 current == candidate.original_sha256 || current == candidate.optimized_sha256,
325 "source changed since plan: {}",
326 candidate.path.display()
327 );
328 Ok((source, original, optimized, current))
329}
330
331fn blob(directory: &Path, folder: &str, digest: &str) -> Result<Vec<u8>> {
332 let path = contained_file(directory, &Path::new(folder).join(format!("{digest}.png")))?;
333 let data = read_bounded(&path)?;
334 ensure!(
335 hash(&data) == digest,
336 "artifact hash mismatch: {}",
337 path.display()
338 );
339 Ok(data)
340}
341
342fn event(file: &mut fs::File, operation: &str, status: &str, path: &Path) -> Result<()> {
343 serde_json::to_writer(
344 &mut *file,
345 &serde_json::json!({"operation":operation,"status":status,"path":path}),
346 )?;
347 file.write_all(b"\n")?;
348 file.sync_all()?;
349 Ok(())
350}