Skip to main content

specfold_core/files/
fetch.rs

1//! Bringing a document's stored attachments back onto disk, verified by hash.
2
3use super::{hash, store};
4use crate::corpus::Corpus;
5use crate::model::Doc;
6use anyhow::{Context, Result, bail};
7use serde::Serialize;
8
9#[derive(Debug, Serialize)]
10pub struct Fetched {
11    pub path: String,
12    /// `fetched`, `present` (already on disk with the right hash).
13    pub state: String,
14}
15
16/// Brings a document's stored files to disk. A file already there with other content is left
17/// alone unless `force` (CLI-4): nothing is fetched while any would be overwritten.
18pub fn fetch(c: &Corpus, doc: &Doc, force: bool) -> Result<Vec<Fetched>> {
19    let entries: Vec<_> = doc.files().into_iter().filter(|f| f.store == "store").collect();
20    if entries.is_empty() {
21        return Ok(vec![]);
22    }
23    let s = store::open(&c.cfg)?.context("no [files.store] is configured")?;
24    let base = c.cfg.root.join(doc.base_dir());
25    if !force {
26        let other: Vec<String> = entries
27            .iter()
28            .filter(|f| {
29                let dst = base.join(&f.path);
30                dst.is_file() && !hash::file(&dst).map(|h| h.0 == f.sha256).unwrap_or(false)
31            })
32            .map(|f| f.path.clone())
33            .collect();
34        if !other.is_empty() {
35            bail!(
36                "{} already exist(s) with other content; nothing was fetched. Move it aside, or run spec fetch {} --force to overwrite",
37                other.join(", "),
38                doc.id
39            );
40        }
41    }
42    let mut out = vec![];
43    for f in entries {
44        let dst = base.join(&f.path);
45        if dst.is_file() && hash::file(&dst).map(|h| h.0 == f.sha256).unwrap_or(false) {
46            out.push(Fetched { path: f.path, state: "present".into() });
47            continue;
48        }
49        // C127: the bytes land beside the target and replace it only once verified.
50        let name = dst.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
51        let part = dst.with_file_name(format!(".{name}.specfold-part"));
52        if let Err(e) = s.get(&f.sha256, &part) {
53            let _ = std::fs::remove_file(&part);
54            return Err(e);
55        }
56        let (got, _) = hash::file(&part)?;
57        if got != f.sha256 {
58            let _ = std::fs::remove_file(&part);
59            // An environment problem (the store holds other bytes), not a usage error: exit 3.
60            return Err(anyhow::Error::new(std::io::Error::new(
61                std::io::ErrorKind::InvalidData,
62                format!("{} arrived with sha256 {got}, expected {}; nothing was written", f.path, f.sha256),
63            )));
64        }
65        std::fs::rename(&part, &dst)?;
66        out.push(Fetched { path: f.path, state: "fetched".into() });
67    }
68    Ok(out)
69}