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