Skip to main content

cranpose_services/
bundled_assets.rs

1//! Read-only assets packaged with the application.
2
3#[cfg(not(target_arch = "wasm32"))]
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use crate::registry::ServiceRegistry;
8
9/// Error reading an asset from the application bundle.
10#[derive(Clone, Debug, thiserror::Error)]
11pub enum BundledAssetError {
12    /// The requested asset does not exist.
13    #[error("bundled asset `{0}` was not found")]
14    NotFound(String),
15    /// The platform could not read the asset.
16    #[error("could not read bundled asset `{path}`: {message}")]
17    ReadFailed {
18        /// Bundle-relative asset path.
19        path: String,
20        /// Platform error.
21        message: String,
22    },
23    /// The installation declaration contains an unsafe or empty path.
24    #[error("invalid bundled asset path `{0}`")]
25    InvalidPath(String),
26    /// Files could not be installed into application storage.
27    #[error("could not install bundled assets at {path}: {message}")]
28    InstallFailed {
29        /// Destination path being changed.
30        path: String,
31        /// Filesystem error.
32        message: String,
33    },
34}
35
36/// One bundle-relative file in a declarative asset installation.
37#[cfg(not(target_arch = "wasm32"))]
38#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct BundledAssetEntry {
40    /// Path below the set's bundle source root.
41    pub source: PathBuf,
42    /// Path below the set's installation directory.
43    pub destination: PathBuf,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47impl BundledAssetEntry {
48    /// Creates an entry that keeps the same relative path at the destination.
49    pub fn new(path: impl Into<PathBuf>) -> Self {
50        let path = path.into();
51        Self {
52            source: path.clone(),
53            destination: path,
54        }
55    }
56
57    /// Creates an entry whose installed relative path differs from its bundle path.
58    pub fn mapped(source: impl Into<PathBuf>, destination: impl Into<PathBuf>) -> Self {
59        Self {
60            source: source.into(),
61            destination: destination.into(),
62        }
63    }
64}
65
66/// A versioned set copied from the application bundle into writable storage.
67#[cfg(not(target_arch = "wasm32"))]
68#[derive(Clone, Debug, PartialEq, Eq, Hash)]
69pub struct BundledAssetInstallSpec {
70    /// Version written only after every entry is installed.
71    pub version: String,
72    /// Optional bundle-relative prefix shared by every source entry.
73    pub source_root: PathBuf,
74    /// Writable directory containing the installed entries and version stamp.
75    pub destination: PathBuf,
76    /// Files belonging to this set.
77    pub entries: Vec<BundledAssetEntry>,
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81impl BundledAssetInstallSpec {
82    /// Starts an installation declaration.
83    pub fn new(version: impl Into<String>, destination: impl Into<PathBuf>) -> Self {
84        Self {
85            version: version.into(),
86            source_root: PathBuf::new(),
87            destination: destination.into(),
88            entries: Vec::new(),
89        }
90    }
91
92    /// Sets the shared bundle-relative source prefix.
93    pub fn source_root(mut self, source_root: impl Into<PathBuf>) -> Self {
94        self.source_root = source_root.into();
95        self
96    }
97
98    /// Adds one file to the set.
99    pub fn entry(mut self, entry: BundledAssetEntry) -> Self {
100        self.entries.push(entry);
101        self
102    }
103}
104
105/// Result of installing a bundled asset set.
106#[cfg(not(target_arch = "wasm32"))]
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum BundledAssetInstallOutcome {
109    /// This host has no application-bundle reader.
110    Unavailable,
111    /// The requested version and every declared file were already present.
112    Current,
113    /// Files were copied and the requested version was committed.
114    Installed,
115}
116
117/// Access to files packaged in the app bundle.
118///
119/// Reads are synchronous, which is what a packaged file on a native platform
120/// is: Android's asset manager, an application bundle and a directory beside
121/// the executable all answer without waiting on a network. A browser has no
122/// such file — its resources arrive over HTTP — so the web registers no backend
123/// and [`bundled_assets`] answers `None` there. An application that ships
124/// resources to the web fetches them through [`crate::http`], which is async
125/// because that is what the medium is.
126pub trait BundledAssets: Send + Sync {
127    /// Reads one bundle-relative asset in full.
128    ///
129    /// Use [`open`](BundledAssets::open) for anything that should not be held
130    /// in memory all at once — a bundled model, a video, a database seed.
131    fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError>;
132
133    /// Opens one bundle-relative asset for chunked reading.
134    ///
135    /// The default reads the whole asset and hands it back a chunk at a time,
136    /// which is honest for a backend that can only produce the bytes at once.
137    /// Backends with a real streaming API — Android's `AssetManager`, a file in
138    /// an application bundle — override it and never materialise the asset.
139    fn open(&self, path: &str) -> Result<Box<dyn BundledAssetReader>, BundledAssetError> {
140        Ok(Box::new(StreamingAssetReader::new(
141            path,
142            std::io::Cursor::new(self.read(path)?),
143        )))
144    }
145
146    /// The asset's byte length without reading it, when the backend knows it.
147    fn len(&self, path: &str) -> Option<u64> {
148        let _ = path;
149        None
150    }
151}
152
153/// Chunked reader over one bundled asset.
154///
155/// Synchronous and `Send` so a worker thread can drain it into a model loader
156/// or a database without involving the UI thread.
157pub trait BundledAssetReader: Send {
158    /// Reads the next chunk, or `None` at end of asset.
159    fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError>;
160}
161
162/// A [`BundledAssetReader`] over any byte stream.
163///
164/// Every backend that streams an asset ends at the same loop: fill a chunk,
165/// stop at the end, name the asset rather than its resolved location when the
166/// read fails. What differs is only what was opened — a file beside the
167/// executable, a file in an application bundle, a span of an Android package,
168/// or bytes already in hand — and whether the asset ends where its stream
169/// does. The loop lives here once; a backend supplies the stream and, when
170/// its asset is a span of something larger, how many bytes of it are its own.
171pub struct StreamingAssetReader<R> {
172    source: R,
173    path: String,
174    remaining: Option<u64>,
175}
176
177impl<R: std::io::Read + Send> StreamingAssetReader<R> {
178    /// A reader over a stream whose end is the asset's end.
179    pub fn new(path: impl Into<String>, source: R) -> Self {
180        Self {
181            source,
182            path: path.into(),
183            remaining: None,
184        }
185    }
186
187    /// A reader over `len` bytes of a stream that continues past the asset —
188    /// an Android package's file descriptor addresses the whole package, so
189    /// the asset's end is a count rather than end of file.
190    pub fn with_length(path: impl Into<String>, source: R, len: u64) -> Self {
191        Self {
192            source,
193            path: path.into(),
194            remaining: Some(len),
195        }
196    }
197}
198
199impl<R: std::io::Read + Send> BundledAssetReader for StreamingAssetReader<R> {
200    fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError> {
201        let want = match self.remaining {
202            Some(0) => return Ok(None),
203            Some(remaining) => remaining.min(crate::content::DEFAULT_CHUNK_LEN as u64) as usize,
204            None => crate::content::DEFAULT_CHUNK_LEN,
205        };
206        let mut chunk = vec![0u8; want];
207        let mut filled = 0;
208        while filled < chunk.len() {
209            match self.source.read(&mut chunk[filled..]) {
210                Ok(0) => break,
211                Ok(read) => filled += read,
212                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
213                Err(error) => {
214                    return Err(BundledAssetError::ReadFailed {
215                        path: self.path.clone(),
216                        message: error.to_string(),
217                    });
218                }
219            }
220        }
221        if filled == 0 {
222            self.remaining = Some(0);
223            return Ok(None);
224        }
225        chunk.truncate(filled);
226        if let Some(remaining) = &mut self.remaining {
227            *remaining -= filled as u64;
228        }
229        Ok(Some(chunk))
230    }
231}
232
233/// Shared bundled-assets service.
234pub type BundledAssetsRef = Arc<dyn BundledAssets>;
235
236static PLATFORM_BUNDLED_ASSETS: ServiceRegistry<dyn BundledAssets> = ServiceRegistry::new();
237
238/// Installs a platform bundled-assets reader.
239pub fn set_platform_bundled_assets(assets: BundledAssetsRef) {
240    PLATFORM_BUNDLED_ASSETS.set(assets);
241}
242
243/// Removes the platform reader.
244pub fn clear_platform_bundled_assets() {
245    PLATFORM_BUNDLED_ASSETS.clear();
246}
247
248/// Returns the platform reader, if this host has an application bundle.
249pub fn bundled_assets() -> Option<BundledAssetsRef> {
250    PLATFORM_BUNDLED_ASSETS.get()
251}
252
253/// Installs a declarative set without exposing bundle APIs or partial-file
254/// handling to the application.
255///
256/// Each file is replaced through a sibling temporary file. The version stamp
257/// is committed last, so an interrupted run is retried on the next call and is
258/// never mistaken for a current installation.
259#[cfg(not(target_arch = "wasm32"))]
260pub fn install_bundled_asset_set(
261    spec: &BundledAssetInstallSpec,
262) -> Result<BundledAssetInstallOutcome, BundledAssetError> {
263    validate_spec(spec)?;
264    let Some(assets) = bundled_assets() else {
265        return Ok(BundledAssetInstallOutcome::Unavailable);
266    };
267    let stamp = spec.destination.join(".cranpose-assets-version");
268    let current = std::fs::read_to_string(&stamp).ok();
269    if current.as_deref() == Some(spec.version.as_str())
270        && spec
271            .entries
272            .iter()
273            .all(|entry| spec.destination.join(&entry.destination).is_file())
274    {
275        return Ok(BundledAssetInstallOutcome::Current);
276    }
277
278    std::fs::create_dir_all(&spec.destination)
279        .map_err(|error| install_error(&spec.destination, error))?;
280    for entry in &spec.entries {
281        let source = spec.source_root.join(&entry.source);
282        let source = path_for_bundle(&source)?;
283        let bytes = assets.read(&source)?;
284        let target = spec.destination.join(&entry.destination);
285        if let Some(parent) = target.parent() {
286            std::fs::create_dir_all(parent).map_err(|error| install_error(parent, error))?;
287        }
288        replace_file(&target, &bytes)?;
289    }
290    replace_file(&stamp, spec.version.as_bytes())?;
291    Ok(BundledAssetInstallOutcome::Installed)
292}
293
294#[cfg(not(target_arch = "wasm32"))]
295fn validate_spec(spec: &BundledAssetInstallSpec) -> Result<(), BundledAssetError> {
296    if spec.version.is_empty() || spec.entries.is_empty() {
297        return Err(BundledAssetError::InvalidPath(String::new()));
298    }
299    validate_relative(&spec.source_root)?;
300    for entry in &spec.entries {
301        validate_relative(&entry.source)?;
302        validate_relative(&entry.destination)?;
303    }
304    Ok(())
305}
306
307#[cfg(not(target_arch = "wasm32"))]
308fn validate_relative(path: &Path) -> Result<(), BundledAssetError> {
309    if path.as_os_str().is_empty() {
310        return Ok(());
311    }
312    if path.components().any(|component| {
313        matches!(
314            component,
315            Component::ParentDir | Component::RootDir | Component::Prefix(_)
316        )
317    }) {
318        return Err(BundledAssetError::InvalidPath(path.display().to_string()));
319    }
320    Ok(())
321}
322
323#[cfg(not(target_arch = "wasm32"))]
324fn path_for_bundle(path: &Path) -> Result<String, BundledAssetError> {
325    validate_relative(path)?;
326    let mut result = String::new();
327    for component in path.components() {
328        if matches!(component, Component::CurDir) {
329            continue;
330        }
331        if !result.is_empty() {
332            result.push('/');
333        }
334        result.push_str(&component.as_os_str().to_string_lossy());
335    }
336    if result.is_empty() {
337        return Err(BundledAssetError::InvalidPath(path.display().to_string()));
338    }
339    Ok(result)
340}
341
342#[cfg(not(target_arch = "wasm32"))]
343fn replace_file(target: &Path, bytes: &[u8]) -> Result<(), BundledAssetError> {
344    use std::io::Write;
345
346    let file_name = target
347        .file_name()
348        .and_then(|name| name.to_str())
349        .ok_or_else(|| BundledAssetError::InvalidPath(target.display().to_string()))?;
350    let temporary = target.with_file_name(format!(".{file_name}.cranpose-part"));
351    let mut output =
352        std::fs::File::create(&temporary).map_err(|error| install_error(&temporary, error))?;
353    output
354        .write_all(bytes)
355        .and_then(|()| output.sync_all())
356        .map_err(|error| install_error(&temporary, error))?;
357    if !target.exists() {
358        return std::fs::rename(&temporary, target).map_err(|error| install_error(target, error));
359    }
360
361    let backup = target.with_file_name(format!(".{file_name}.cranpose-backup"));
362    if backup.exists() {
363        std::fs::remove_file(&backup).map_err(|error| install_error(&backup, error))?;
364    }
365    std::fs::rename(target, &backup).map_err(|error| install_error(target, error))?;
366    if let Err(error) = std::fs::rename(&temporary, target) {
367        let _ = std::fs::rename(&backup, target);
368        return Err(install_error(target, error));
369    }
370    std::fs::remove_file(&backup).map_err(|error| install_error(&backup, error))
371}
372
373#[cfg(not(target_arch = "wasm32"))]
374fn install_error(path: &Path, error: std::io::Error) -> BundledAssetError {
375    BundledAssetError::InstallFailed {
376        path: path.display().to_string(),
377        message: error.to_string(),
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    struct WholeAssets;
386
387    impl BundledAssets for WholeAssets {
388        fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError> {
389            match path {
390                "big.bin" => Ok(vec![4u8; crate::content::DEFAULT_CHUNK_LEN + 9]),
391                "small.txt" => Ok(b"hello".to_vec()),
392                other => Err(BundledAssetError::NotFound(other.to_string())),
393            }
394        }
395    }
396
397    #[test]
398    fn a_length_bounded_reader_stops_at_the_asset_and_not_at_the_stream() {
399        let asset_len = crate::content::DEFAULT_CHUNK_LEN + 5;
400        let mut package = vec![7u8; asset_len];
401        package.extend_from_slice(&[9u8; 64]);
402
403        let mut reader = StreamingAssetReader::with_length(
404            "model.bin",
405            std::io::Cursor::new(package),
406            asset_len as u64,
407        );
408        let mut read = Vec::new();
409        while let Some(chunk) = reader.read_chunk().expect("chunks read") {
410            read.extend_from_slice(&chunk);
411        }
412
413        assert_eq!(
414            read.len(),
415            asset_len,
416            "the asset ends where its length says"
417        );
418        assert!(
419            read.iter().all(|byte| *byte == 7),
420            "no byte of what follows the asset in the package is handed out"
421        );
422    }
423
424    #[test]
425    fn the_default_reader_streams_a_whole_asset_in_chunks() {
426        let assets = WholeAssets;
427        let mut reader = assets.open("big.bin").expect("the asset opens");
428        let mut sizes = Vec::new();
429        let mut total = 0usize;
430        while let Some(chunk) = reader.read_chunk().expect("chunks read") {
431            sizes.push(chunk.len());
432            total += chunk.len();
433        }
434        assert_eq!(sizes, vec![crate::content::DEFAULT_CHUNK_LEN, 9]);
435        assert_eq!(total, crate::content::DEFAULT_CHUNK_LEN + 9);
436    }
437
438    #[test]
439    fn a_missing_asset_fails_to_open() {
440        assert!(matches!(
441            WholeAssets.open("absent.bin").err(),
442            Some(BundledAssetError::NotFound(_))
443        ));
444    }
445
446    #[test]
447    fn a_backend_that_cannot_stat_reports_no_length() {
448        assert_eq!(WholeAssets.len("small.txt"), None);
449    }
450
451    use std::{
452        collections::BTreeMap,
453        sync::atomic::{AtomicU64, Ordering},
454    };
455
456    #[test]
457    fn registration_round_trips() {
458        let _guard = crate::registry::test_service_guard();
459        struct Fake;
460        impl BundledAssets for Fake {
461            fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError> {
462                Ok(path.as_bytes().to_vec())
463            }
464        }
465        set_platform_bundled_assets(Arc::new(Fake));
466        assert_eq!(
467            bundled_assets().unwrap().read("models/a").unwrap(),
468            b"models/a"
469        );
470        clear_platform_bundled_assets();
471        assert!(bundled_assets().is_none());
472    }
473
474    #[cfg(not(target_arch = "wasm32"))]
475    struct MapAssets(BTreeMap<String, Vec<u8>>);
476
477    #[cfg(not(target_arch = "wasm32"))]
478    impl BundledAssets for MapAssets {
479        fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError> {
480            self.0
481                .get(path)
482                .cloned()
483                .ok_or_else(|| BundledAssetError::NotFound(path.to_string()))
484        }
485    }
486
487    #[cfg(not(target_arch = "wasm32"))]
488    fn test_directory() -> PathBuf {
489        static NEXT: AtomicU64 = AtomicU64::new(1);
490        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
491            .join("../../target/test-output/bundled-assets")
492            .join(format!(
493                "{}-{}",
494                std::process::id(),
495                NEXT.fetch_add(1, Ordering::Relaxed)
496            ))
497    }
498
499    #[cfg(not(target_arch = "wasm32"))]
500    #[test]
501    fn declarative_set_installs_and_detects_current_version() {
502        let _guard = crate::registry::test_service_guard();
503        let destination = test_directory();
504        set_platform_bundled_assets(Arc::new(MapAssets(BTreeMap::from([
505            ("models/a.bin".to_string(), vec![1, 2]),
506            ("models/nested/b.bin".to_string(), vec![3]),
507        ]))));
508        let spec = BundledAssetInstallSpec::new("7", &destination)
509            .source_root("models")
510            .entry(BundledAssetEntry::new("a.bin"))
511            .entry(BundledAssetEntry::mapped("nested/b.bin", "b.bin"));
512        assert_eq!(
513            install_bundled_asset_set(&spec).unwrap(),
514            BundledAssetInstallOutcome::Installed
515        );
516        assert_eq!(std::fs::read(destination.join("a.bin")).unwrap(), [1, 2]);
517        assert_eq!(std::fs::read(destination.join("b.bin")).unwrap(), [3]);
518        assert_eq!(
519            install_bundled_asset_set(&spec).unwrap(),
520            BundledAssetInstallOutcome::Current
521        );
522        std::fs::remove_dir_all(destination).unwrap();
523    }
524
525    #[cfg(not(target_arch = "wasm32"))]
526    #[test]
527    fn declaration_rejects_parent_paths_and_handles_missing_host() {
528        let _guard = crate::registry::test_service_guard();
529        clear_platform_bundled_assets();
530        let invalid = BundledAssetInstallSpec::new("1", test_directory())
531            .entry(BundledAssetEntry::new("../outside"));
532        assert!(matches!(
533            install_bundled_asset_set(&invalid),
534            Err(BundledAssetError::InvalidPath(_))
535        ));
536        let valid = BundledAssetInstallSpec::new("1", test_directory())
537            .entry(BundledAssetEntry::new("inside"));
538        assert_eq!(
539            install_bundled_asset_set(&valid).unwrap(),
540            BundledAssetInstallOutcome::Unavailable
541        );
542    }
543}