1#[cfg(not(target_arch = "wasm32"))]
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use crate::registry::ServiceRegistry;
8
9#[derive(Clone, Debug, thiserror::Error)]
11pub enum BundledAssetError {
12 #[error("bundled asset `{0}` was not found")]
14 NotFound(String),
15 #[error("could not read bundled asset `{path}`: {message}")]
17 ReadFailed {
18 path: String,
20 message: String,
22 },
23 #[error("invalid bundled asset path `{0}`")]
25 InvalidPath(String),
26 #[error("could not install bundled assets at {path}: {message}")]
28 InstallFailed {
29 path: String,
31 message: String,
33 },
34}
35
36#[cfg(not(target_arch = "wasm32"))]
38#[derive(Clone, Debug, PartialEq, Eq, Hash)]
39pub struct BundledAssetEntry {
40 pub source: PathBuf,
42 pub destination: PathBuf,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47impl BundledAssetEntry {
48 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 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#[cfg(not(target_arch = "wasm32"))]
68#[derive(Clone, Debug, PartialEq, Eq, Hash)]
69pub struct BundledAssetInstallSpec {
70 pub version: String,
72 pub source_root: PathBuf,
74 pub destination: PathBuf,
76 pub entries: Vec<BundledAssetEntry>,
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81impl BundledAssetInstallSpec {
82 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 pub fn source_root(mut self, source_root: impl Into<PathBuf>) -> Self {
94 self.source_root = source_root.into();
95 self
96 }
97
98 pub fn entry(mut self, entry: BundledAssetEntry) -> Self {
100 self.entries.push(entry);
101 self
102 }
103}
104
105#[cfg(not(target_arch = "wasm32"))]
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum BundledAssetInstallOutcome {
109 Unavailable,
111 Current,
113 Installed,
115}
116
117pub trait BundledAssets: Send + Sync {
127 fn read(&self, path: &str) -> Result<Vec<u8>, BundledAssetError>;
132
133 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 fn len(&self, path: &str) -> Option<u64> {
148 let _ = path;
149 None
150 }
151}
152
153pub trait BundledAssetReader: Send {
158 fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, BundledAssetError>;
160}
161
162pub struct StreamingAssetReader<R> {
172 source: R,
173 path: String,
174 remaining: Option<u64>,
175}
176
177impl<R: std::io::Read + Send> StreamingAssetReader<R> {
178 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 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
233pub type BundledAssetsRef = Arc<dyn BundledAssets>;
235
236static PLATFORM_BUNDLED_ASSETS: ServiceRegistry<dyn BundledAssets> = ServiceRegistry::new();
237
238pub fn set_platform_bundled_assets(assets: BundledAssetsRef) {
240 PLATFORM_BUNDLED_ASSETS.set(assets);
241}
242
243pub fn clear_platform_bundled_assets() {
245 PLATFORM_BUNDLED_ASSETS.clear();
246}
247
248pub fn bundled_assets() -> Option<BundledAssetsRef> {
250 PLATFORM_BUNDLED_ASSETS.get()
251}
252
253#[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}