Skip to main content

resopt/
plan.rs

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
49/// Creates a new, self-contained plan directory. Sources are never modified.
50/// A partial directory may remain if IO fails; it cannot apply without plan.json.
51pub 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            // Check source and catalog remained stable during encoding.
103            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
219/// Apply a reviewed plan; no encoding or implicit approval occurs here.
220pub fn apply(directory: impl AsRef<Path>) -> Result<ApplyReport> {
221    execute(directory.as_ref(), false)
222}
223
224/// Restore only files that still match this plan's original or candidate hashes.
225pub fn restore(directory: impl AsRef<Path>) -> Result<ApplyReport> {
226    execute(directory.as_ref(), true)
227}
228
229struct Lock(PathBuf);
230impl Drop for Lock {
231    fn drop(&mut self) {
232        let _ = fs::remove_file(&self.0);
233    }
234}
235
236fn execute(directory: &Path, restoring: bool) -> Result<ApplyReport> {
237    let directory = fs::canonicalize(directory)?;
238    let lock_path = directory.join(".lock");
239    write_new(
240        &lock_path,
241        format!("pid={}\n", std::process::id()).as_bytes(),
242    )
243    .context(
244        "plan locked; remove .lock only after confirming no resopt process is using this plan",
245    )?;
246    let _lock = Lock(lock_path);
247    let plan = read_plan(&directory)?;
248    let root_lock_path = plan.root.join(".resopt.lock");
249    write_new(&root_lock_path, format!("pid={}\n", std::process::id()).as_bytes())
250        .context("project locked; remove .resopt.lock only after confirming no resopt process is modifying this project")?;
251    let _root_lock = Lock(root_lock_path);
252    let inventory = scan_with_options(
253        &plan.root,
254        crate::ScanOptions {
255            include_ignored: true,
256        },
257    )?;
258    let assets: BTreeMap<_, _> = inventory
259        .assets
260        .into_iter()
261        .map(|asset| (asset.path.clone(), asset))
262        .collect();
263    // Full preflight prevents a stale later entry from producing a partial batch.
264    for candidate in &plan.candidates {
265        let asset = assets
266            .get(&candidate.path)
267            .context("candidate is no longer referenced by a supported catalog")?;
268        ensure!(
269            asset.eligible
270                && asset.contents_path == candidate.contents_path
271                && asset.contents_sha256 == candidate.contents_sha256,
272            "catalog eligibility or Contents.json changed: {}",
273            candidate.path.display()
274        );
275        verify_entry(&plan, &directory, candidate)?;
276    }
277    let journal_path = directory.join("journal.jsonl");
278    if fs::symlink_metadata(&journal_path).is_ok() {
279        contained_file(&directory, Path::new("journal.jsonl"))?;
280    }
281    let mut journal = fs::OpenOptions::new()
282        .append(true)
283        .create(true)
284        .open(journal_path)?;
285    let mut report = ApplyReport::default();
286    for candidate in &plan.candidates {
287        // Recheck right before replacement, in addition to the batch preflight.
288        let (source, original, optimized, current) = verify_entry(&plan, &directory, candidate)?;
289        let (target, expected_hash) = if restoring {
290            (&original, &candidate.original_sha256)
291        } else {
292            (&optimized, &candidate.optimized_sha256)
293        };
294        if current == *expected_hash {
295            report.already_current += 1;
296            continue;
297        }
298        let operation = if restoring { "restore" } else { "apply" };
299        event(&mut journal, operation, "started", &candidate.path)?;
300        replace(&source, target).with_context(|| {
301            format!(
302                "{operation} failed; originals remain in {}; run resopt restore to recover",
303                directory.display()
304            )
305        })?;
306        read_verified(&source, expected_hash)?;
307        event(&mut journal, operation, "completed", &candidate.path)?;
308        report.changed += 1;
309        if !restoring {
310            report.source_bytes_saved += candidate.original_bytes - candidate.optimized_bytes;
311        }
312    }
313    Ok(report)
314}
315
316type VerifiedEntry = (PathBuf, Vec<u8>, Vec<u8>, String);
317
318fn verify_entry(plan: &Plan, directory: &Path, candidate: &Candidate) -> Result<VerifiedEntry> {
319    let source = contained_file(&plan.root, &candidate.path)?;
320    read_verified(
321        &contained_file(&plan.root, &candidate.contents_path)?,
322        &candidate.contents_sha256,
323    )?;
324    let original = blob(directory, "originals", &candidate.original_sha256)?;
325    let optimized = blob(directory, "candidates", &candidate.optimized_sha256)?;
326    ensure!(
327        original.len() as u64 == candidate.original_bytes
328            && optimized.len() as u64 == candidate.optimized_bytes,
329        "candidate sizes do not match blobs"
330    );
331    optimizer::verify(&original, &optimized)?;
332    let current = hash(&read_bounded(&source)?);
333    ensure!(
334        current == candidate.original_sha256 || current == candidate.optimized_sha256,
335        "source changed since plan: {}",
336        candidate.path.display()
337    );
338    Ok((source, original, optimized, current))
339}
340
341fn blob(directory: &Path, folder: &str, digest: &str) -> Result<Vec<u8>> {
342    let path = contained_file(directory, &Path::new(folder).join(format!("{digest}.png")))?;
343    let data = read_bounded(&path)?;
344    ensure!(
345        hash(&data) == digest,
346        "artifact hash mismatch: {}",
347        path.display()
348    );
349    Ok(data)
350}
351
352fn event(file: &mut fs::File, operation: &str, status: &str, path: &Path) -> Result<()> {
353    serde_json::to_writer(
354        &mut *file,
355        &serde_json::json!({"operation":operation,"status":status,"path":path}),
356    )?;
357    file.write_all(b"\n")?;
358    file.sync_all()?;
359    Ok(())
360}