Skip to main content

dotloom_io/
dotl.rs

1//! `.dotl` project files (ADR-0005).
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use dotloom_document::{Document, Limits, MigrationNote, PropValue, SCHEMA_VERSION, TypeId};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use thiserror::Error;
10
11use crate::zip::{Archive, ZipError, ZipLimits, write};
12
13/// Container format version written by this library.
14pub const FORMAT_VERSION: u32 = 1;
15/// MIME type.
16pub const MEDIA_TYPE: &str = "application/vnd.dotloom.project+zip";
17/// Prefix of text properties that reference an asset: `asset:assets/<sha256>.png`.
18pub const ASSET_PREFIX: &str = "asset:";
19
20/// A plugin type used by the document.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct PluginRequirement {
24    /// Type ID.
25    pub type_id: TypeId,
26    /// Highest type version used.
27    pub version: u32,
28}
29
30/// Asset metadata.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct AssetInfo {
34    /// Path inside the container (`assets/<sha256>.<ext>`).
35    pub path: String,
36    /// Media type.
37    pub media_type: String,
38    /// Size in bytes.
39    pub size: u64,
40    /// Hex SHA-256 of the content.
41    pub sha256: String,
42}
43
44/// Who wrote the file.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Producer {
47    /// Name.
48    pub name: String,
49    /// Version.
50    pub version: String,
51}
52
53/// `manifest.json`.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct Manifest {
57    /// Always `"dotloom"`.
58    pub format: String,
59    /// Container version.
60    pub format_version: u32,
61    /// Document schema version of `document.json`.
62    pub schema_version: u32,
63    /// Producer.
64    pub producer: Producer,
65    /// Plugin types required to edit every entity.
66    #[serde(default)]
67    pub plugins: Vec<PluginRequirement>,
68    /// Assets.
69    #[serde(default)]
70    pub assets: Vec<AssetInfo>,
71    /// Unknown fields preserved.
72    #[serde(flatten)]
73    pub extra: BTreeMap<String, Value>,
74}
75
76/// An in-memory `.dotl` project.
77#[derive(Debug, Clone, PartialEq)]
78pub struct DotlFile {
79    /// The document.
80    pub document: Document,
81    /// Optional view state (camera, panels); never geometry.
82    pub view: Option<Value>,
83    /// Assets by container path.
84    pub assets: BTreeMap<String, Vec<u8>>,
85    /// Entries this version does not understand, preserved on save.
86    pub unknown_entries: BTreeMap<String, Vec<u8>>,
87    /// Unknown manifest fields, preserved on save.
88    pub manifest_extra: BTreeMap<String, Value>,
89}
90
91impl DotlFile {
92    /// Wrap a document.
93    #[must_use]
94    pub fn new(document: Document) -> Self {
95        Self {
96            document,
97            view: None,
98            assets: BTreeMap::new(),
99            unknown_entries: BTreeMap::new(),
100            manifest_extra: BTreeMap::new(),
101        }
102    }
103}
104
105/// Load report.
106#[derive(Debug, Clone, Default, PartialEq)]
107pub struct LoadReport {
108    /// Schema migrations applied.
109    pub migrations: Vec<MigrationNote>,
110    /// Plugins required by the file.
111    pub plugins: Vec<PluginRequirement>,
112    /// Warnings (preserved unknown entries, ...).
113    pub warnings: Vec<String>,
114}
115
116/// `.dotl` errors.
117#[derive(Debug, Clone, PartialEq, Error)]
118#[non_exhaustive]
119pub enum DotlError {
120    /// Container problem.
121    #[error(transparent)]
122    Zip(#[from] ZipError),
123    /// Required entry missing.
124    #[error("missing `{0}` in the container")]
125    MissingEntry(&'static str),
126    /// Manifest problem.
127    #[error("invalid manifest: {0}")]
128    Manifest(String),
129    /// Container written by a newer, incompatible Dotloom.
130    #[error("file format version {found} is newer than supported version {supported}")]
131    FutureFormat {
132        /// Found.
133        found: u32,
134        /// Supported.
135        supported: u32,
136    },
137    /// Document problem.
138    #[error("invalid document: {0}")]
139    Document(String),
140    /// Asset referenced but missing or corrupt.
141    #[error("asset `{0}`: {1}")]
142    Asset(String, String),
143}
144
145/// Reader limits.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147pub struct DotlLimits {
148    /// Container limits.
149    pub zip: ZipLimits,
150    /// Document limits.
151    pub document: Limits,
152}
153
154fn hex(bytes: &[u8]) -> String {
155    bytes.iter().map(|b| format!("{b:02x}")).collect()
156}
157
158/// SHA-256 hex digest.
159#[must_use]
160pub fn sha256_hex(data: &[u8]) -> String {
161    hex(&Sha256::digest(data))
162}
163
164/// Asset paths referenced by the document (`asset:` text properties).
165#[must_use]
166pub fn referenced_assets(doc: &Document) -> BTreeSet<String> {
167    doc.entities()
168        .flat_map(|e| e.props.values())
169        .filter_map(|v| match v {
170            PropValue::Text(t) => t.strip_prefix(ASSET_PREFIX).map(ToOwned::to_owned),
171            _ => None,
172        })
173        .collect()
174}
175
176/// Plugin requirements of a document.
177#[must_use]
178pub fn plugin_requirements(doc: &Document) -> Vec<PluginRequirement> {
179    let mut m: BTreeMap<TypeId, u32> = BTreeMap::new();
180    for e in doc.entities() {
181        if e.type_id.namespace() != "dotloom" {
182            let v = m.entry(e.type_id.clone()).or_insert(0);
183            *v = (*v).max(e.type_version);
184        }
185    }
186    m.into_iter().map(|(type_id, version)| PluginRequirement { type_id, version }).collect()
187}
188
189fn media_type(path: &str) -> &'static str {
190    match path.rsplit('.').next().unwrap_or("") {
191        "png" => "image/png",
192        "jpg" | "jpeg" => "image/jpeg",
193        "svg" => "image/svg+xml",
194        "ttf" => "font/ttf",
195        "woff2" => "font/woff2",
196        "json" => "application/json",
197        _ => "application/octet-stream",
198    }
199}
200
201/// Store an asset and return its container path (content addressed).
202pub fn add_asset(file: &mut DotlFile, ext: &str, data: Vec<u8>) -> String {
203    let ext: String = ext.chars().filter(char::is_ascii_alphanumeric).take(8).collect::<String>().to_ascii_lowercase();
204    let path = format!("assets/{}.{}", sha256_hex(&data), if ext.is_empty() { "bin" } else { &ext });
205    file.assets.insert(path.clone(), data);
206    path
207}
208
209/// Serialize a project to `.dotl` bytes (deterministic for equal input).
210pub fn write_dotl(file: &DotlFile) -> Result<Vec<u8>, DotlError> {
211    let doc_json = serde_json::to_vec_pretty(&file.document).map_err(|e| DotlError::Document(e.to_string()))?;
212    let referenced = referenced_assets(&file.document);
213    for r in &referenced {
214        if !file.assets.contains_key(r) {
215            return Err(DotlError::Asset(r.clone(), "referenced but missing".into()));
216        }
217    }
218    let assets: Vec<AssetInfo> = file
219        .assets
220        .iter()
221        .map(|(p, d)| AssetInfo {
222            path: p.clone(),
223            media_type: media_type(p).into(),
224            size: d.len() as u64,
225            sha256: sha256_hex(d),
226        })
227        .collect();
228    let manifest = Manifest {
229        format: "dotloom".into(),
230        format_version: FORMAT_VERSION,
231        schema_version: SCHEMA_VERSION,
232        producer: Producer { name: "dotloom".into(), version: env!("CARGO_PKG_VERSION").into() },
233        plugins: plugin_requirements(&file.document),
234        assets,
235        extra: file.manifest_extra.clone(),
236    };
237    let mut entries = vec![
238        (
239            "manifest.json".to_owned(),
240            serde_json::to_vec_pretty(&manifest).map_err(|e| DotlError::Manifest(e.to_string()))?,
241        ),
242        ("document.json".to_owned(), doc_json),
243    ];
244    if let Some(v) = &file.view {
245        entries
246            .push(("view.json".into(), serde_json::to_vec_pretty(v).map_err(|e| DotlError::Manifest(e.to_string()))?));
247    }
248    for (p, d) in &file.assets {
249        entries.push((p.clone(), d.clone()));
250    }
251    for (p, d) in &file.unknown_entries {
252        entries.push((p.clone(), d.clone()));
253    }
254    Ok(write(&entries)?)
255}
256
257/// Parse `.dotl` bytes: bounded container parsing, manifest checks, schema
258/// migration, document validation and asset verification.
259pub fn read_dotl(bytes: &[u8], limits: &DotlLimits) -> Result<(DotlFile, LoadReport), DotlError> {
260    let archive = Archive::parse(bytes, limits.zip)?;
261    let get = |name: &'static str| -> Result<Vec<u8>, DotlError> {
262        let e = archive.entry(name).ok_or(DotlError::MissingEntry(name))?;
263        Ok(archive.read(e)?)
264    };
265    let manifest: Manifest =
266        serde_json::from_slice(&get("manifest.json")?).map_err(|e| DotlError::Manifest(e.to_string()))?;
267    if manifest.format != "dotloom" {
268        return Err(DotlError::Manifest(format!("format is `{}`, expected `dotloom`", manifest.format)));
269    }
270    if manifest.format_version > FORMAT_VERSION {
271        return Err(DotlError::FutureFormat { found: manifest.format_version, supported: FORMAT_VERSION });
272    }
273    let doc_text = get("document.json")?;
274    let (document, migrations) =
275        Document::from_json_slice(&doc_text, &limits.document).map_err(|e| DotlError::Document(e.to_string()))?;
276    let view = match archive.entry("view.json") {
277        Some(e) => Some(
278            serde_json::from_slice(&archive.read(e)?).map_err(|x| DotlError::Manifest(format!("view.json: {x}")))?,
279        ),
280        None => None,
281    };
282    let mut assets = BTreeMap::new();
283    for a in &manifest.assets {
284        let e = archive
285            .entry(&a.path)
286            .ok_or_else(|| DotlError::Asset(a.path.clone(), "listed in the manifest but missing".into()))?;
287        let data = archive.read(e)?;
288        if sha256_hex(&data) != a.sha256 {
289            return Err(DotlError::Asset(a.path.clone(), "checksum mismatch".into()));
290        }
291        assets.insert(a.path.clone(), data);
292    }
293    for r in referenced_assets(&document) {
294        if !assets.contains_key(&r) {
295            return Err(DotlError::Asset(r, "referenced by the document but missing".into()));
296        }
297    }
298    let known: BTreeSet<&str> = ["manifest.json", "document.json", "view.json"].into_iter().collect();
299    let mut unknown_entries = BTreeMap::new();
300    let mut warnings = Vec::new();
301    for e in archive.entries() {
302        if known.contains(e.name.as_str()) || assets.contains_key(&e.name) || e.name.ends_with('/') {
303            continue;
304        }
305        warnings.push(format!("preserved unknown entry `{}`", e.name));
306        unknown_entries.insert(e.name.clone(), archive.read(e)?);
307    }
308    let report = LoadReport { migrations, plugins: manifest.plugins.clone(), warnings };
309    Ok((DotlFile { document, view, assets, unknown_entries, manifest_extra: manifest.extra }, report))
310}
311
312/// Write bytes to `path` atomically: a temporary file in the same directory is
313/// written and flushed, then renamed over the target. On failure the existing file
314/// is left untouched and the temporary file is removed.
315#[cfg(not(target_arch = "wasm32"))]
316pub fn save_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
317    use std::io::Write;
318    let dir = match path.parent() {
319        Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
320        _ => std::path::PathBuf::from("."),
321    };
322    let name = path.file_name().map_or_else(|| "dotloom".into(), |n| n.to_string_lossy().into_owned());
323    let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
324    let result = (|| {
325        let mut f = std::fs::File::create(&tmp)?;
326        f.write_all(bytes)?;
327        f.sync_all()?;
328        drop(f);
329        std::fs::rename(&tmp, path)
330    })();
331    if result.is_err() {
332        let _ = std::fs::remove_file(&tmp);
333    }
334    result
335}