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//! The playbook's container layout has always said `assets/<hash>`; since
5//! P5 it is load-bearing rather than an optimization. A rev file is the
6//! whole document, so a 1.5 MB symbol written into it would be 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 std::path::PathBuf;
18
19use blockworx_doc::{block_model::Asset, hash::AssetHash};
20use serde::{Deserialize, Serialize};
21
22/// How to read a payload's bytes — which is also its file extension. Not
23/// derived from the [`Asset`] variant name: the durable spelling is
24/// lowercase because it is a file suffix.
25#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
26pub enum AssetFormat {
27    #[serde(rename = "svg")]
28    Svg,
29    #[serde(rename = "png")]
30    Png,
31}
32
33impl AssetFormat {
34    /// Every format a payload can be filed under. A hash names one byte
35    /// string, so at most one of these files can exist — which is what
36    /// lets a reader ask for a payload by hash alone.
37    pub const ALL: [Self; 2] = [Self::Svg, Self::Png];
38
39    pub fn of(asset: &Asset) -> Self {
40        match asset {
41            Asset::Svg(_) => Self::Svg,
42            Asset::Png(_) => Self::Png,
43        }
44    }
45
46    pub fn extension(self) -> &'static str {
47        match self {
48            Self::Svg => "svg",
49            Self::Png => "png",
50        }
51    }
52
53    fn asset(self, bytes: Vec<u8>) -> Asset {
54        match self {
55            Self::Svg => Asset::Svg(bytes.into()),
56            Self::Png => Asset::Png(bytes.into()),
57        }
58    }
59
60    /// `<hash>.<ext>`, the name a payload is filed under.
61    pub fn file_name(self, hash: AssetHash) -> String {
62        format!("{hash}.{}", self.extension())
63    }
64}
65
66/// Where payloads are read back from. A trait rather than a directory
67/// because a session with no files reads its own, and Phase 8's browser
68/// reads the same names out of origin storage.
69pub trait AssetSource {
70    /// Where the payload is kept, for a report to name. Not a promise
71    /// that anything is there.
72    fn names(&self, hash: AssetHash) -> PathBuf;
73
74    /// The payload's bytes and the format that reads them, unverified —
75    /// [`hydrate`] is what checks them.
76    ///
77    /// # Errors
78    /// Whatever the backing store says when the payload is not there to
79    /// be read.
80    fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)>;
81}
82
83/// Where payloads are written. Separate from [`AssetSource`] because a
84/// read-only session has one and not the other.
85pub trait AssetSink {
86    /// File `asset` under `hash`, durably, if it is not already there.
87    ///
88    /// # Errors
89    /// The write that did not land.
90    fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()>;
91}
92
93/// Why a payload could not be handed back.
94#[derive(Debug)]
95pub enum Miss {
96    Unreadable(std::io::Error),
97    /// The bytes filed under a hash do not hash to it. Content addressing
98    /// is self-verifying, so this is caught here rather than as a document
99    /// that quietly draws the wrong picture.
100    Corrupt {
101        found: AssetHash,
102    },
103}
104
105/// A reference a document makes and the store cannot honour.
106#[derive(Debug, thiserror::Error)]
107pub struct AssetFault {
108    pub hash: AssetHash,
109    pub path: PathBuf,
110    pub why: Miss,
111}
112
113impl std::fmt::Display for AssetFault {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        let path = self.path.display();
116        match &self.why {
117            Miss::Unreadable(error) => write!(
118                f,
119                "the document places artwork the container no longer holds: {path} ({error})",
120            ),
121            Miss::Corrupt { found } => write!(
122                f,
123                "the document places artwork addressed as {named}, but {path} holds bytes that \
124                 hash to {found} — the payload has been replaced",
125                named = self.hash,
126            ),
127        }
128    }
129}
130
131/// Read `hash`'s payload back and check it against the hash that named it.
132///
133/// # Errors
134/// [`AssetFault`] when the payload is not there, or is not the payload.
135pub fn hydrate(source: &dyn AssetSource, hash: AssetHash) -> Result<Asset, AssetFault> {
136    let fault = |why| AssetFault {
137        hash,
138        path: source.names(hash),
139        why,
140    };
141    let (format, bytes) = source.find(hash).map_err(|e| fault(Miss::Unreadable(e)))?;
142    let found = AssetHash::of(&bytes);
143    if found != hash {
144        return Err(fault(Miss::Corrupt { found }));
145    }
146    Ok(format.asset(bytes))
147}
148
149/// Payloads that die with the process — a session with no container, and
150/// what a test stands a rev store on.
151#[derive(Default, Debug)]
152pub struct Held(blockworx_doc::hash::HashedMap<blockworx_doc::hash::AssetKind, Asset>);
153
154impl AssetSource for Held {
155    fn names(&self, hash: AssetHash) -> PathBuf {
156        PathBuf::from(format!("{hash} in this session"))
157    }
158
159    fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)> {
160        self.0
161            .get(&hash)
162            .map(|asset| (AssetFormat::of(asset), asset.bytes().to_vec()))
163            .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
164    }
165}
166
167impl AssetSink for Held {
168    fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()> {
169        self.0.entry(hash).or_insert_with(|| asset.clone());
170        Ok(())
171    }
172}
173
174#[cfg(not(target_arch = "wasm32"))]
175pub use native::Dir;
176
177#[cfg(not(target_arch = "wasm32"))]
178mod native {
179    use super::{Asset, AssetFormat, AssetHash, AssetSink, AssetSource, PathBuf};
180    use crate::store::container::ASSETS;
181    use std::fs::{File, OpenOptions};
182    use std::io::Write as _;
183    use std::path::Path;
184
185    /// A container's `assets/` directory.
186    pub struct Dir(PathBuf);
187
188    impl Dir {
189        pub fn at(root: &Path) -> Self {
190            Self(root.join(ASSETS))
191        }
192
193        fn path(&self, hash: AssetHash, format: AssetFormat) -> PathBuf {
194            self.0.join(format.file_name(hash))
195        }
196    }
197
198    impl AssetSource for Dir {
199        fn names(&self, hash: AssetHash) -> PathBuf {
200            self.0.join(hash.to_string())
201        }
202
203        fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)> {
204            let mut missing = std::io::Error::from(std::io::ErrorKind::NotFound);
205            for format in AssetFormat::ALL {
206                match std::fs::read(self.path(hash, format)) {
207                    Ok(bytes) => return Ok((format, bytes)),
208                    Err(error) => missing = error,
209                }
210            }
211            Err(missing)
212        }
213    }
214
215    impl AssetSink for Dir {
216        /// Create-only: a file already under a content hash's name already
217        /// holds those bytes, so there is nothing a second write could
218        /// say. The directory entry is fsync'd with the file, because the
219        /// rev that names these bytes is written next.
220        fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()> {
221            std::fs::create_dir_all(&self.0)?;
222            let path = self.path(hash, AssetFormat::of(asset));
223            let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
224                Ok(file) => file,
225                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(()),
226                Err(error) => return Err(error),
227            };
228            file.write_all(asset.bytes())?;
229            file.sync_all()?;
230            sync_dir(&self.0)
231        }
232    }
233
234    #[cfg(unix)]
235    fn sync_dir(dir: &Path) -> std::io::Result<()> {
236        File::open(dir)?.sync_all()
237    }
238
239    #[cfg(not(unix))]
240    fn sync_dir(_dir: &Path) -> std::io::Result<()> {
241        Ok(())
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use blockworx_doc::block_model::Asset;
249
250    /// The format is a file suffix and a durable JSON tag at once, so both
251    /// spellings are pinned where a rename would have to notice them.
252    #[test]
253    fn a_payload_is_filed_under_its_hash_and_its_format() {
254        let asset = Asset::Svg(b"<svg/>".as_slice().into());
255        assert_eq!(AssetFormat::of(&asset), AssetFormat::Svg);
256        assert_eq!(
257            AssetFormat::Svg.file_name(asset.hash()),
258            format!("{}.svg", asset.hash()),
259        );
260        assert_eq!(
261            serde_json::to_string(&AssetFormat::Svg).expect("it serializes"),
262            "\"svg\"",
263        );
264        assert_eq!(
265            AssetFormat::of(&Asset::Png(b"".as_slice().into())).extension(),
266            "png",
267        );
268    }
269
270    /// Asking by hash alone is what lets a rev file carry references and
271    /// nothing else — and the answer is verified against the name.
272    #[test]
273    fn a_payload_comes_back_by_hash_and_is_checked_against_it() {
274        let asset = Asset::Png(b"artwork".as_slice().into());
275        let mut store = Held::default();
276        store.put(asset.hash(), &asset).expect("it is held");
277
278        assert_eq!(hydrate(&store, asset.hash()).expect("it comes back"), asset);
279        assert!(matches!(
280            hydrate(&store, Asset::Svg(b"<svg/>".as_slice().into()).hash()),
281            Err(AssetFault {
282                why: Miss::Unreadable(_),
283                ..
284            }),
285        ));
286    }
287}