From 46bc8d7a0ac906b59ce0c94b074367fd98d84d84 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 10:42:21 +0000 Subject: [PATCH] share FigCraft published library archive members --- crates/figcraft-format/src/library.rs | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/crates/figcraft-format/src/library.rs b/crates/figcraft-format/src/library.rs index fe42dc7a4..9b8b12635 100644 --- a/crates/figcraft-format/src/library.rs +++ b/crates/figcraft-format/src/library.rs @@ -1,5 +1,6 @@ //! Wire schema and member limits for published libraries embedded in a native project archive. use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; pub const MANIFEST: &str = "library/manifest.json"; pub const DOCUMENT: &str = "library/document.json"; @@ -80,6 +81,58 @@ pub fn clip_display(s: &str, max_chars: usize) -> String { s.chars().take(max_chars).collect() } +/// Write the complete published-library member group into a native archive. The consumer maps +/// its document to the neutral manifest and image index; path layout, compression, and shared +/// image reuse stay here. +pub fn write_members<'a, D: Serialize>( + writer: &mut super::ArchiveWriter, + manifest: &Manifest, + document: &D, + image_index: &[super::ImageIndexEntry], + already_written: &BTreeSet, + images: impl IntoIterator, +) -> Result<(), super::ArchiveWriteError> { + let manifest_bytes = serde_json::to_vec(manifest)?; + writer.add(MANIFEST, &manifest_bytes, super::Compression::Level(3))?; + writer.add_document_json(DOCUMENT, document, image_index)?; + for (hash, bytes) in images { + if already_written.contains(hash) || !valid_image_key(hash) { + continue; + } + writer.add(&format!("{IMAGES_PREFIX}{hash}"), bytes, super::Compression::Stored)?; + } + Ok(()) +} + +/// Read only published-library image resources selected by the consumer's document model. +/// Missing and oversized images are omitted, leaving the consumer to render its established +/// placeholder. The shared layer handles path choice, safe member names, and byte accounting. +pub fn read_image_resources( + archive: &mut impl super::NativeArchive, + hashes: impl IntoIterator, + already_available: &BTreeSet, + unpacked: &mut super::ReadBudget, +) -> BTreeMap> { + let mut remaining = MAX_IMAGE_BYTES; + let mut images = BTreeMap::new(); + for hash in hashes { + if already_available.contains(&hash) || !valid_image_key(&hash) { + continue; + } + let preferred = format!("{IMAGES_PREFIX}{hash}"); + let fallback = format!("images/{hash}"); + let Some(name) = [preferred, fallback].into_iter().find(|name| archive.contains(name)) else { continue }; + let Ok(bytes) = unpacked.read(archive, &name, remaining.min(super::MAX_MEMBER)) else { continue }; + remaining = remaining.saturating_sub(bytes.len() as u64); + images.insert(hash, bytes); + } + images +} + +fn valid_image_key(key: &str) -> bool { + (1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + #[cfg(test)] mod tests { use super::*; @@ -104,4 +157,37 @@ mod tests { assert!(!is_valid_id("../bad")); assert_eq!(clip_display("aé🙂b", 2), "aé"); } + + #[test] + fn library_member_writer_and_reader_own_resource_layout_and_budgets() { + let mut writer = super::super::ArchiveWriter::new(); + let manifest = Manifest { id: "library-1".into(), ..Manifest::default() }; + let already_written = BTreeSet::from(["existing".to_string()]); + write_members( + &mut writer, + &manifest, + &serde_json::json!({"name":"library snapshot"}), + &[], + &already_written, + [("stored", &b"library bytes"[..]), ("existing", &b"duplicate"[..]), ("../bad", &b"hostile"[..])], + ) + .unwrap(); + writer.add("images/fallback", b"fallback bytes", super::super::Compression::Stored).unwrap(); + let bytes = writer.finish().unwrap(); + let mut archive = super::super::Archive::open(&bytes, super::super::ArchiveLimits { max_entries: 8, max_total_bytes: 1024 }).unwrap(); + assert!(archive.contains(MANIFEST)); + assert!(archive.contains(DOCUMENT)); + assert!(archive.contains("library/images/stored")); + assert!(!archive.contains("library/images/existing")); + assert!(!archive.contains("library/images/../bad")); + + let available = BTreeSet::from(["existing".to_string()]); + let mut budget = super::super::ReadBudget::new(1024); + let images = + read_image_resources(&mut archive, ["stored".into(), "existing".into(), "fallback".into(), "../bad".into()], &available, &mut budget); + assert_eq!(images["stored"], b"library bytes"); + assert_eq!(images["fallback"], b"fallback bytes"); + assert!(!images.contains_key("existing")); + assert!(!images.contains_key("../bad")); + } }