1use 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
13pub const FORMAT_VERSION: u32 = 1;
15pub const MEDIA_TYPE: &str = "application/vnd.dotloom.project+zip";
17pub const ASSET_PREFIX: &str = "asset:";
19
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct PluginRequirement {
24 pub type_id: TypeId,
26 pub version: u32,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct AssetInfo {
34 pub path: String,
36 pub media_type: String,
38 pub size: u64,
40 pub sha256: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Producer {
47 pub name: String,
49 pub version: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct Manifest {
57 pub format: String,
59 pub format_version: u32,
61 pub schema_version: u32,
63 pub producer: Producer,
65 #[serde(default)]
67 pub plugins: Vec<PluginRequirement>,
68 #[serde(default)]
70 pub assets: Vec<AssetInfo>,
71 #[serde(flatten)]
73 pub extra: BTreeMap<String, Value>,
74}
75
76#[derive(Debug, Clone, PartialEq)]
78pub struct DotlFile {
79 pub document: Document,
81 pub view: Option<Value>,
83 pub assets: BTreeMap<String, Vec<u8>>,
85 pub unknown_entries: BTreeMap<String, Vec<u8>>,
87 pub manifest_extra: BTreeMap<String, Value>,
89}
90
91impl DotlFile {
92 #[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#[derive(Debug, Clone, Default, PartialEq)]
107pub struct LoadReport {
108 pub migrations: Vec<MigrationNote>,
110 pub plugins: Vec<PluginRequirement>,
112 pub warnings: Vec<String>,
114}
115
116#[derive(Debug, Clone, PartialEq, Error)]
118#[non_exhaustive]
119pub enum DotlError {
120 #[error(transparent)]
122 Zip(#[from] ZipError),
123 #[error("missing `{0}` in the container")]
125 MissingEntry(&'static str),
126 #[error("invalid manifest: {0}")]
128 Manifest(String),
129 #[error("file format version {found} is newer than supported version {supported}")]
131 FutureFormat {
132 found: u32,
134 supported: u32,
136 },
137 #[error("invalid document: {0}")]
139 Document(String),
140 #[error("asset `{0}`: {1}")]
142 Asset(String, String),
143}
144
145#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147pub struct DotlLimits {
148 pub zip: ZipLimits,
150 pub document: Limits,
152}
153
154fn hex(bytes: &[u8]) -> String {
155 bytes.iter().map(|b| format!("{b:02x}")).collect()
156}
157
158#[must_use]
160pub fn sha256_hex(data: &[u8]) -> String {
161 hex(&Sha256::digest(data))
162}
163
164#[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#[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
201pub 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
209pub 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
257pub 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#[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}