Skip to main content

blockworx_store/
assets.rs

1//! Artwork at the container boundary: `assets/<hash>.<ext>` is the one
2//! home for bytes, and everything else keeps only the hash.
3//!
4//! Content addressing is load-bearing rather than an optimization. A rev
5//! file is the whole document, so a 1.5 MB symbol written into it would be
6//! written
7//! again into every rev that stands after it — and the same file is
8//! git-diffed and read on every open. Content addressing means the bytes
9//! land once, are never rewritten, and are never deleted: reclaiming what
10//! nothing references belongs to compaction, not to an edit.
11//!
12//! `crates/doc` knows nothing about any of this. A document is stripped on
13//! its way into a rev file and re-attached on its way out
14//! ([`Document::without_assets`](blockworx_doc::document::Document::without_assets)),
15//! so the document crate only ever sees payloads with their bytes in hand.
16
17use blockworx_doc::{block_model::Asset, hash::AssetHash};
18use serde::{Deserialize, Serialize};
19
20/// How to read a payload's bytes — which is also its file extension. Not
21/// derived from the [`Asset`] variant name: the durable spelling is
22/// lowercase because it is a file suffix.
23#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
24pub enum AssetFormat {
25    #[serde(rename = "svg")]
26    Svg,
27    #[serde(rename = "png")]
28    Png,
29}
30
31impl AssetFormat {
32    /// Every format a payload can be filed under. A hash names one byte
33    /// string, so at most one of these files can exist — which is what
34    /// lets a reader ask for a payload by hash alone.
35    pub const ALL: [Self; 2] = [Self::Svg, Self::Png];
36
37    pub fn of(asset: &Asset) -> Self {
38        match asset {
39            Asset::Svg(_) => Self::Svg,
40            Asset::Png(_) => Self::Png,
41        }
42    }
43
44    pub fn extension(self) -> &'static str {
45        match self {
46            Self::Svg => "svg",
47            Self::Png => "png",
48        }
49    }
50
51    fn asset(self, bytes: Vec<u8>) -> Asset {
52        match self {
53            Self::Svg => Asset::Svg(bytes.into()),
54            Self::Png => Asset::Png(bytes.into()),
55        }
56    }
57
58    /// `<hash>.<ext>`, the name a payload is filed under.
59    pub fn file_name(self, hash: AssetHash) -> String {
60        format!("{hash}.{}", self.extension())
61    }
62}
63
64/// Where payloads are read back from. A trait rather than a directory
65/// because a session with no files reads its own, and the browser reads the
66/// same names out of origin storage.
67pub trait AssetSource {
68    /// What a report calls the payload's home. Not a platform path — the
69    /// storage underneath is what knows whether there is one — and not a
70    /// promise that anything is there.
71    fn names(&self, hash: AssetHash) -> String;
72
73    /// The payload's bytes and the format that reads them, unverified —
74    /// [`hydrate`] is what checks them.
75    ///
76    /// # Errors
77    /// Whatever the backing store says when the payload is not there to
78    /// be read.
79    fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)>;
80}
81
82/// Where payloads are written. Separate from [`AssetSource`] because a
83/// read-only session has one and not the other.
84pub trait AssetSink {
85    /// File `asset` under `hash`, durably, if it is not already there.
86    ///
87    /// # Errors
88    /// The write that did not land.
89    fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()>;
90}
91
92/// Why a payload could not be handed back.
93#[derive(Debug)]
94pub enum Miss {
95    Unreadable(std::io::Error),
96    /// The bytes filed under a hash do not hash to it. Content addressing
97    /// is self-verifying, so this is caught here rather than as a document
98    /// that quietly draws the wrong picture.
99    Corrupt {
100        found: AssetHash,
101    },
102}
103
104/// A reference a document makes and the store cannot honour.
105#[derive(Debug, thiserror::Error)]
106pub struct AssetFault {
107    pub hash: AssetHash,
108    pub at: String,
109    pub why: Miss,
110}
111
112impl std::fmt::Display for AssetFault {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        let path = &self.at;
115        match &self.why {
116            Miss::Unreadable(error) => write!(
117                f,
118                "the document places artwork the container no longer holds: {path} ({error})",
119            ),
120            Miss::Corrupt { found } => write!(
121                f,
122                "the document places artwork addressed as {named}, but {path} holds bytes that \
123                 hash to {found} — the payload has been replaced",
124                named = self.hash,
125            ),
126        }
127    }
128}
129
130/// Read `hash`'s payload back and check it against the hash that named it.
131///
132/// # Errors
133/// [`AssetFault`] when the payload is not there, or is not the payload.
134pub fn hydrate(source: &dyn AssetSource, hash: AssetHash) -> Result<Asset, AssetFault> {
135    let fault = |why| AssetFault {
136        hash,
137        at: source.names(hash),
138        why,
139    };
140    let (format, bytes) = source.find(hash).map_err(|e| fault(Miss::Unreadable(e)))?;
141    let found = AssetHash::of(&bytes);
142    if found != hash {
143        return Err(fault(Miss::Corrupt { found }));
144    }
145    Ok(format.asset(bytes))
146}
147
148/// Payloads that die with the process — a session with no container, and
149/// what a test stands a rev store on.
150#[derive(Default, Debug)]
151pub struct Held(blockworx_doc::hash::HashedMap<blockworx_doc::hash::AssetKind, Asset>);
152
153impl AssetSource for Held {
154    fn names(&self, hash: AssetHash) -> String {
155        format!("{hash} in this session")
156    }
157
158    fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)> {
159        self.0
160            .get(&hash)
161            .map(|asset| (AssetFormat::of(asset), asset.bytes().to_vec()))
162            .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
163    }
164}
165
166impl AssetSink for Held {
167    fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()> {
168        self.0.entry(hash).or_insert_with(|| asset.clone());
169        Ok(())
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use blockworx_doc::block_model::Asset;
177
178    /// The format is a file suffix and a durable JSON tag at once, so both
179    /// spellings are pinned where a rename would have to notice them.
180    #[test]
181    fn a_payload_is_filed_under_its_hash_and_its_format() {
182        let asset = Asset::Svg(b"<svg/>".as_slice().into());
183        assert_eq!(AssetFormat::of(&asset), AssetFormat::Svg);
184        assert_eq!(
185            AssetFormat::Svg.file_name(asset.hash()),
186            format!("{}.svg", asset.hash()),
187        );
188        assert_eq!(
189            serde_json::to_string(&AssetFormat::Svg).expect("it serializes"),
190            "\"svg\"",
191        );
192        assert_eq!(
193            AssetFormat::of(&Asset::Png(b"".as_slice().into())).extension(),
194            "png",
195        );
196    }
197
198    /// Asking by hash alone is what lets a rev file carry references and
199    /// nothing else — and the answer is verified against the name.
200    #[test]
201    fn a_payload_comes_back_by_hash_and_is_checked_against_it() {
202        let asset = Asset::Png(b"artwork".as_slice().into());
203        let mut store = Held::default();
204        store.put(asset.hash(), &asset).expect("it is held");
205
206        assert_eq!(hydrate(&store, asset.hash()).expect("it comes back"), asset);
207        assert!(matches!(
208            hydrate(&store, Asset::Svg(b"<svg/>".as_slice().into()).hash()),
209            Err(AssetFault {
210                why: Miss::Unreadable(_),
211                ..
212            }),
213        ));
214    }
215}