mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
feat(figcraft-format): add bounded native archive access
This commit is contained in:
1 parent
3c67a98e54
commit
111b522d05
3 files changed
+136
-12
No files matched your search
Generated
+10
@@ -1445,6 +1445,16 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-figcraft-format"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-zip",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-figma-rest"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -13,4 +13,5 @@ workspace = true
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
craft-zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -10,23 +10,37 @@ pub const FORMAT: &str = "figcraft";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Header { pub version: Option<u64>, pub images: Option<Vec<String>> }
|
||||
pub struct Header {
|
||||
pub version: Option<u64>,
|
||||
pub images: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub fn read_header(text: &str) -> Result<Header, serde_json::Error> { serde_json::from_str(text) }
|
||||
pub fn read_header(text: &str) -> Result<Header, serde_json::Error> {
|
||||
serde_json::from_str(text)
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Header {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct V;
|
||||
impl<'de> Visitor<'de> for V {
|
||||
type Value = Header;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("a FigCraft document object") }
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("a FigCraft document object")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut m: A) -> Result<Header, A::Error> {
|
||||
let mut h = Header::default();
|
||||
while let Some(k) = m.next_key::<String>()? {
|
||||
match k.as_str() {
|
||||
"version" => h.version = m.next_value::<Value>()?.as_u64(),
|
||||
"imageIndex" => h.images = m.next_value::<Value>()?.as_array().map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()),
|
||||
_ => { m.next_value::<IgnoredAny>()?; }
|
||||
"imageIndex" => {
|
||||
h.images = m
|
||||
.next_value::<Value>()?
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect())
|
||||
}
|
||||
_ => {
|
||||
m.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(h)
|
||||
@@ -41,16 +55,31 @@ impl<'de> Deserialize<'de> for Header {
|
||||
pub struct NewerVersion(pub u32);
|
||||
|
||||
pub fn check_version(h: &Header) -> Result<(), NewerVersion> {
|
||||
if let Some(v) = h.version && v > u64::from(VERSION) { return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); }
|
||||
if let Some(v) = h.version
|
||||
&& v > u64::from(VERSION)
|
||||
{
|
||||
return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ImageIndexEntry { pub hash: String, pub width: u32, pub height: u32 }
|
||||
pub struct ImageIndexEntry {
|
||||
pub hash: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
pub fn write_document_json<D: Serialize, W: std::io::Write>(doc: &D, images: &[ImageIndexEntry], w: W) -> Result<(), serde_json::Error> {
|
||||
#[derive(Serialize)]
|
||||
struct Flat<'a, D: ?Sized> { format: &'static str, version: u32, #[serde(rename="imageIndex")] images: &'a [ImageIndexEntry], #[serde(flatten)] document: &'a D }
|
||||
struct Flat<'a, D: ?Sized> {
|
||||
format: &'static str,
|
||||
version: u32,
|
||||
#[serde(rename = "imageIndex")]
|
||||
images: &'a [ImageIndexEntry],
|
||||
#[serde(flatten)]
|
||||
document: &'a D,
|
||||
}
|
||||
Flat { format: FORMAT, version: VERSION, images, document: doc }.serialize(&mut serde_json::Serializer::new(w))
|
||||
}
|
||||
|
||||
@@ -60,7 +89,8 @@ mod tests {
|
||||
#[test]
|
||||
fn header_skips_app_fields() {
|
||||
let h = read_header(r#"{"version":1,"imageIndex":[{"hash":"abc"}],"nested":{"unused":true}}"#).unwrap();
|
||||
assert_eq!(h.version, Some(1)); assert_eq!(h.images, Some(vec!["abc".into()]));
|
||||
assert_eq!(h.version, Some(1));
|
||||
assert_eq!(h.images, Some(vec!["abc".into()]));
|
||||
}
|
||||
#[test]
|
||||
fn newer_version_is_reported_before_document_decode() {
|
||||
@@ -69,9 +99,92 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn writer_flattens_document_fields() {
|
||||
let doc = serde_json::json!({"name":"example"}); let mut bytes=Vec::new();
|
||||
let doc = serde_json::json!({"name":"example"});
|
||||
let mut bytes = Vec::new();
|
||||
write_document_json(&doc, &[], &mut bytes).unwrap();
|
||||
let v: Value=serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["format"], FORMAT); assert_eq!(v["version"], VERSION); assert_eq!(v["name"], "example");
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(v["format"], FORMAT);
|
||||
assert_eq!(v["version"], VERSION);
|
||||
assert_eq!(v["name"], "example");
|
||||
}
|
||||
}
|
||||
|
||||
/// Limits for the native ZIP package reader.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ArchiveLimits {
|
||||
pub max_entries: u64,
|
||||
pub max_total_bytes: u64,
|
||||
}
|
||||
|
||||
/// A bounded reader for a native `.figcraft` ZIP package.
|
||||
pub struct Archive<'a> {
|
||||
inner: craft_zip::Archive<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
pub fn open(bytes: &'a [u8], limits: ArchiveLimits) -> Result<Self, craft_zip::Error> {
|
||||
let inner = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: limits.max_entries, max_total_bytes: limits.max_total_bytes })?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
pub fn names(&self) -> impl Iterator<Item = std::borrow::Cow<'_, str>> {
|
||||
self.inner.names()
|
||||
}
|
||||
|
||||
pub fn has_duplicate_names(&self) -> bool {
|
||||
self.inner.has_duplicate_names()
|
||||
}
|
||||
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.inner.contains(name)
|
||||
}
|
||||
|
||||
pub fn read(&mut self, name: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, craft_zip::Error> {
|
||||
self.inner.read(name, max_bytes)
|
||||
}
|
||||
|
||||
pub fn budget_used(&self) -> u64 {
|
||||
self.inner.budget().used()
|
||||
}
|
||||
}
|
||||
|
||||
/// One member to write into a native ZIP package.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Member<'a> {
|
||||
pub name: &'a str,
|
||||
pub bytes: &'a [u8],
|
||||
pub compression: craft_zip::Compression,
|
||||
}
|
||||
|
||||
/// Write a deterministic native ZIP package using the shared ZIP writer.
|
||||
pub fn write_archive<'a>(members: impl IntoIterator<Item = Member<'a>>) -> Result<Vec<u8>, craft_zip::Error> {
|
||||
let mut writer = craft_zip::Writer::new();
|
||||
for member in members {
|
||||
writer.add(member.name, member.bytes, member.compression)?;
|
||||
}
|
||||
writer.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod archive_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bounded_archive_reader_and_writer_round_trip_members() {
|
||||
let bytes =
|
||||
write_archive([Member { name: "document.json", bytes: br#"{"version":1}"#, compression: craft_zip::Compression::Deflated }]).unwrap();
|
||||
let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap();
|
||||
assert!(archive.contains("document.json"));
|
||||
assert_eq!(archive.read("document.json", 32).unwrap(), Some(br#"{"version":1}"#.to_vec()));
|
||||
assert_eq!(archive.budget_used(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_reader_enforces_entry_and_total_limits() {
|
||||
let bytes = write_archive([Member { name: "document.json", bytes: b"123456789", compression: craft_zip::Compression::Deflated }]).unwrap();
|
||||
let mut small_entry = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 100 }).unwrap();
|
||||
assert!(small_entry.read("document.json", 8).is_err());
|
||||
let mut small_total = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 8 }).unwrap();
|
||||
assert!(small_total.read("document.json", 100).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user