Skip to main content

ashlar_store/
format.rs

1//! Versioned document files: an envelope, a migration chain, and durable saves.
2//!
3//! A file is UTF-8 JSON:
4//!
5//! ```json
6//! { "format": "acme.drawing", "schema_version": 3, "generator": "Acme Draw 1.2.0", "document": { … } }
7//! ```
8//!
9//! - **Versioning.** The schema this build writes is `migrations.len() + 1`. Older files are
10//!   upgraded on load by single-step migrations (`migrations[i]` upgrades `i + 1` to `i + 2`)
11//!   operating on the whole JSON envelope, so each historical shape needs one small function.
12//!   Append one per schema bump and never edit a shipped one; a bump that only adds fields is a
13//!   no-op step, and still worth it: older builds then refuse the file instead of silently dropping
14//!   the new data when they save.
15//! - **Refusals.** A file from a newer schema is refused ([`FormatError::TooNew`]) instead of being
16//!   half-read and later overwritten; a file of another format is refused
17//!   ([`FormatError::WrongFormat`]).
18//! - **Pre-envelope files.** [`Format::legacy`] recognises bare documents written before the
19//!   envelope existed and says which schema they are; the first migration then wraps them.
20//! - **Other envelope keys** (state outside the schema's promises, such as which views were open)
21//!   are kept in [`Loaded::extras`] and written back by [`Format::encode_with`].
22//!   They are written before the payload key, or after it with [`Format::extras_after_payload`]
23//!   (so files written as `{format, schema_version, generator, payload, extras…}` re-encode
24//!   byte for byte).
25//! - **Untagged envelopes.** [`Format::accept_untagged`] reads envelopes without a `format` tag
26//!   (written by early builds) when they have `schema_version` and the payload key.
27//! - **Parse errors** keep serde's message and position: [`FormatError::json`].
28//! - **Saves** go through [`ashlar_fs::atomic_write`].
29
30use std::fmt;
31use std::marker::PhantomData;
32use std::path::Path;
33
34use serde::de::{DeserializeOwned, DeserializeSeed, IgnoredAny, MapAccess, Visitor};
35use serde::{Deserialize, Deserializer, Serialize};
36use serde_json::{Map, Value};
37
38/// Upgrades a whole file from schema `n` to `n + 1`.
39pub type Migration = fn(Value) -> Result<Value, String>;
40
41/// Recognises a pre-envelope document and returns its schema version.
42pub type LegacyProbe = fn(&Value) -> Option<u64>;
43
44/// Largest file [`Format::load`] reads unless set with [`Format::max_bytes`].
45pub const DEFAULT_MAX_BYTES: u64 = 2 << 30;
46
47const FORMAT: &str = "format";
48const SCHEMA: &str = "schema_version";
49const GENERATOR: &str = "generator";
50
51#[derive(Debug, thiserror::Error)]
52pub enum FormatError {
53    #[error("{0}")]
54    Io(#[from] std::io::Error),
55    /// For malformed or truncated JSON the message is serde's, position included, and `detail`
56    /// has it structured (also for valid JSON a payload could not be read from).
57    #[error("the file is damaged: {message}")]
58    Corrupt { message: String, detail: Option<JsonError> },
59    #[error("not a {expected} file: {why}")]
60    NotThisFormat {
61        expected: String,
62        why: String,
63        /// The serde error behind the refusal, when there was one (`why` is then its message).
64        detail: Option<JsonError>,
65    },
66    #[error("this is a `{found}` file, not `{expected}`")]
67    WrongFormat { found: String, expected: String },
68    #[error("this file was saved by a newer version (schema v{found}); this build reads up to v{supported}. Update to open it.")]
69    TooNew { found: u64, supported: u64 },
70    #[error("schema v{found} is too old for this build (oldest supported: v{oldest})")]
71    TooOld { found: u64, oldest: u64 },
72    #[error("upgrading from schema v{from} to v{}: {msg}", from.saturating_add(1))]
73    Migration { from: u64, msg: String },
74    #[error("could not encode the document: {0}")]
75    Encode(String),
76}
77
78/// A serde_json error as the parser reported it.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct JsonError {
81    /// serde's message, position included (`"… at line 3 column 7"`).
82    pub message: String,
83    /// 1-based; 0 when serde has no position.
84    pub line: usize,
85    pub column: usize,
86    /// Malformed or truncated JSON, as opposed to valid JSON of the wrong shape.
87    pub syntax: bool,
88}
89
90impl JsonError {
91    pub fn new(e: &serde_json::Error) -> JsonError {
92        JsonError { message: e.to_string(), line: e.line(), column: e.column(), syntax: e.is_syntax() || e.is_eof() }
93    }
94}
95
96impl FormatError {
97    /// A damaged file, for a reason that is not a serde error.
98    pub fn corrupt(message: impl Into<String>) -> FormatError {
99        FormatError::Corrupt { message: message.into(), detail: None }
100    }
101
102    /// A damaged file: serde's error, its message after `context` when there is one.
103    pub fn corrupt_json(context: &str, e: &serde_json::Error) -> FormatError {
104        let message = if context.is_empty() { e.to_string() } else { format!("{context}: {e}") };
105        FormatError::Corrupt { message, detail: Some(JsonError::new(e)) }
106    }
107
108    /// The serde error behind a [`FormatError::NotThisFormat`] or a [`FormatError::Corrupt`], with
109    /// its position.
110    pub fn json(&self) -> Option<&JsonError> {
111        match self {
112            FormatError::NotThisFormat { detail, .. } | FormatError::Corrupt { detail, .. } => detail.as_ref(),
113            _ => None,
114        }
115    }
116}
117
118/// The envelope fields of a file.
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
120pub struct Envelope {
121    pub format: String,
122    pub schema_version: u64,
123    pub generator: Option<String>,
124}
125
126/// A document read from a file.
127#[derive(Debug)]
128pub struct Loaded<D> {
129    pub doc: D,
130    /// The envelope as found in the file (`schema_version` before migration).
131    pub envelope: Envelope,
132    /// Other top-level keys of the envelope (after migration).
133    pub extras: Map<String, Value>,
134}
135
136impl<D> Loaded<D> {
137    /// The file was in an older schema and was upgraded on load (saving it rewrites it in the
138    /// current schema, which older builds can't read: worth telling the user).
139    pub fn migrated(&self, current: u64) -> bool {
140        self.envelope.schema_version < current
141    }
142}
143
144/// How documents of type `D` are stored.
145pub struct Format<D> {
146    id: String,
147    generator: String,
148    payload_key: String,
149    migrations: Vec<Migration>,
150    legacy: Option<LegacyProbe>,
151    oldest: u64,
152    pretty: bool,
153    max_bytes: u64,
154    extras_after_payload: bool,
155    accept_untagged: bool,
156    _doc: PhantomData<fn() -> D>,
157}
158
159impl<D> fmt::Debug for Format<D> {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        f.debug_struct("Format").field("id", &self.id).field("schema_version", &(self.migrations.len() as u64 + 1)).finish()
162    }
163}
164
165impl<D: Serialize + DeserializeOwned> Format<D> {
166    /// Files tagged `id` (e.g. `"acme.drawing"`), written by `generator` (e.g. `"Acme Draw 1.2.0"`).
167    pub fn new(id: impl Into<String>, generator: impl Into<String>) -> Format<D> {
168        Format {
169            id: id.into(),
170            generator: generator.into(),
171            payload_key: "document".into(),
172            migrations: Vec::new(),
173            legacy: None,
174            oldest: 1,
175            pretty: true,
176            max_bytes: DEFAULT_MAX_BYTES,
177            extras_after_payload: false,
178            accept_untagged: false,
179            _doc: PhantomData,
180        }
181    }
182
183    /// The migration chain (`[v1→v2, v2→v3, …]`).
184    pub fn migrations(mut self, m: Vec<Migration>) -> Self {
185        self.migrations = m;
186        self
187    }
188
189    /// The envelope key holding the document (default `"document"`).
190    pub fn payload_key(mut self, key: impl Into<String>) -> Self {
191        self.payload_key = key.into();
192        self
193    }
194
195    /// Recognise bare pre-envelope documents.
196    pub fn legacy(mut self, probe: LegacyProbe) -> Self {
197        self.legacy = Some(probe);
198        self
199    }
200
201    /// Refuse files older than `v` ([`FormatError::TooOld`]).
202    pub fn oldest(mut self, v: u64) -> Self {
203        self.oldest = v.max(1);
204        self
205    }
206
207    /// Indented (diffable, the default) or compact (autosaves) output.
208    pub fn pretty(mut self, on: bool) -> Self {
209        self.pretty = on;
210        self
211    }
212
213    /// Refuse files larger than this.
214    pub fn max_bytes(mut self, n: u64) -> Self {
215        self.max_bytes = n;
216        self
217    }
218
219    /// Write extra envelope keys after the payload key instead of before it (default `false`).
220    /// Reading accepts either order.
221    pub fn extras_after_payload(mut self, on: bool) -> Self {
222        self.extras_after_payload = on;
223        self
224    }
225
226    /// Read envelopes that have no `format` tag (early files) when they have a `schema_version`
227    /// and the payload key; such files are reported with this format's id. Off by default:
228    /// they are refused as [`FormatError::NotThisFormat`]. A `format` tag naming another format is
229    /// always refused.
230    pub fn accept_untagged(mut self, on: bool) -> Self {
231        self.accept_untagged = on;
232        self
233    }
234
235    pub fn id(&self) -> &str {
236        &self.id
237    }
238
239    /// The schema this build writes and the newest it reads.
240    pub fn schema_version(&self) -> u64 {
241        self.migrations.len() as u64 + 1
242    }
243
244    // ------------------------------------------------------------------ writing
245
246    /// The file bytes for `doc` in the current schema.
247    pub fn encode(&self, doc: &D) -> Result<Vec<u8>, FormatError> {
248        self.encode_with(doc, &Map::new())
249    }
250
251    /// [`Format::encode`] with extra envelope keys (envelope fields and the payload key win).
252    pub fn encode_with(&self, doc: &D, extras: &Map<String, Value>) -> Result<Vec<u8>, FormatError> {
253        let out = EnvelopeOut {
254            format: &self.id,
255            schema_version: self.schema_version(),
256            generator: &self.generator,
257            key: &self.payload_key,
258            doc,
259            extras,
260            extras_after: self.extras_after_payload,
261        };
262        let r = if self.pretty { serde_json::to_vec_pretty(&out) } else { serde_json::to_vec(&out) };
263        r.map_err(|e| FormatError::Encode(e.to_string()))
264    }
265
266    /// Encode and write `doc` to `path` durably.
267    pub fn save(&self, path: &Path, doc: &D) -> Result<(), FormatError> {
268        let bytes = self.encode(doc)?;
269        ashlar_fs::atomic_write(path, &bytes)?;
270        Ok(())
271    }
272
273    // ------------------------------------------------------------------ reading
274
275    /// Read a file from disk.
276    pub fn load(&self, path: &Path) -> Result<Loaded<D>, FormatError> {
277        let len = std::fs::metadata(path)?.len();
278        if len > self.max_bytes {
279            return Err(FormatError::corrupt(format!("file is {len} bytes, more than the {} allowed", self.max_bytes)));
280        }
281        let bytes = std::fs::read(path)?;
282        self.decode(&bytes)
283    }
284
285    /// Read file bytes (any supported schema).
286    pub fn decode(&self, bytes: &[u8]) -> Result<Loaded<D>, FormatError> {
287        if bytes.len() as u64 > self.max_bytes {
288            return Err(FormatError::corrupt(format!("{} bytes, more than the {} allowed", bytes.len(), self.max_bytes)));
289        }
290        let mut de = serde_json::Deserializer::from_slice(bytes);
291        let probe = ProbeSeed { key: &self.payload_key }.deserialize(&mut de).and_then(|p| de.end().map(|()| p)).map_err(|e| self.parse_error(e))?;
292        match probe.schema_version {
293            Some(v) => {
294                let found = version_of(&v)?;
295                match probe.format {
296                    Some(f) if f == self.id => {}
297                    Some(f) => return Err(FormatError::WrongFormat { found: f, expected: self.id.clone() }),
298                    None if self.accept_untagged && probe.has_payload => {}
299                    None => return Err(self.not_this("no `format` tag")),
300                }
301                self.check_supported(found)?;
302                if found == self.schema_version() {
303                    // Fast path: one pass straight into `D`.
304                    let mut de = serde_json::Deserializer::from_slice(bytes);
305                    let (doc, extras, generator) = EnvelopeSeed::<D> { key: &self.payload_key, _d: PhantomData }
306                        .deserialize(&mut de)
307                        .map_err(|e| FormatError::corrupt_json("", &e))?;
308                    let doc = doc.ok_or_else(|| FormatError::corrupt(format!("missing `{}`", self.payload_key)))?;
309                    let envelope = Envelope { format: self.id.clone(), schema_version: found, generator };
310                    return Ok(Loaded { doc, envelope, extras });
311                }
312                let value: Value = serde_json::from_slice(bytes).map_err(|e| self.parse_error(e))?;
313                self.upgrade(value, found)
314            }
315            None => {
316                let value: Value = serde_json::from_slice(bytes).map_err(|e| self.parse_error(e))?;
317                let found = self.legacy.and_then(|p| p(&value)).ok_or_else(|| self.not_this("no `schema_version`"))?;
318                self.check_supported(found)?;
319                self.upgrade(value, found)
320            }
321        }
322    }
323
324    /// Run the migrations from `found` and read the result.
325    fn upgrade(&self, value: Value, found: u64) -> Result<Loaded<D>, FormatError> {
326        let generator = value.get(GENERATOR).and_then(Value::as_str).map(str::to_string);
327        let format = value.get(FORMAT).and_then(Value::as_str).unwrap_or(&self.id).to_string();
328        let envelope = Envelope { format, schema_version: found, generator };
329        let mut out = migrate(&self.migrations, value, found)?;
330        let Some(obj) = out.as_object_mut() else { return Err(FormatError::corrupt("not a JSON object")) };
331        let payload = obj
332            .remove(&self.payload_key)
333            .ok_or_else(|| FormatError::corrupt(format!("after upgrading from schema v{found}: missing `{}`", self.payload_key)))?;
334        let doc = D::deserialize(payload).map_err(|e| FormatError::corrupt_json(&format!("after upgrading from schema v{found}"), &e))?;
335        for k in [FORMAT, SCHEMA, GENERATOR] {
336            obj.remove(k);
337        }
338        Ok(Loaded { doc, envelope, extras: std::mem::take(obj) })
339    }
340
341    fn check_supported(&self, found: u64) -> Result<(), FormatError> {
342        if found > self.schema_version() {
343            return Err(FormatError::TooNew { found, supported: self.schema_version() });
344        }
345        if found < self.oldest {
346            return Err(FormatError::TooOld { found, oldest: self.oldest });
347        }
348        Ok(())
349    }
350
351    fn not_this(&self, why: &str) -> FormatError {
352        FormatError::NotThisFormat { expected: self.id.clone(), why: why.into(), detail: None }
353    }
354
355    fn parse_error(&self, e: serde_json::Error) -> FormatError {
356        if e.is_syntax() || e.is_eof() {
357            FormatError::corrupt_json("", &e)
358        } else {
359            FormatError::NotThisFormat { expected: self.id.clone(), why: e.to_string(), detail: Some(JsonError::new(&e)) }
360        }
361    }
362}
363
364/// Run `table` on `doc` from schema `from` up to `table.len() + 1`, stamping `schema_version`
365/// after each step.
366pub fn migrate(table: &[Migration], mut doc: Value, from: u64) -> Result<Value, FormatError> {
367    let target = table.len() as u64 + 1;
368    if from > target {
369        return Err(FormatError::TooNew { found: from, supported: target });
370    }
371    if from == 0 {
372        return Err(FormatError::TooOld { found: 0, oldest: 1 });
373    }
374    for v in from..target {
375        let step = usize::try_from(v - 1).ok().and_then(|i| table.get(i)).ok_or(FormatError::Migration { from: v, msg: "no migration".into() })?;
376        doc = step(doc).map_err(|msg| FormatError::Migration { from: v, msg })?;
377        match doc.as_object_mut() {
378            Some(o) => {
379                o.insert(SCHEMA.into(), Value::from(v + 1));
380            }
381            None => return Err(FormatError::Migration { from: v, msg: "migration produced a non-object document".into() }),
382        }
383    }
384    Ok(doc)
385}
386
387/// A schema version as found in a file: a non-negative integer. Integers too large for `u64`
388/// (written as floats by JSON) are from the far future.
389fn version_of(v: &Value) -> Result<u64, FormatError> {
390    if let Some(n) = v.as_u64() {
391        return Ok(n);
392    }
393    match v.as_f64() {
394        Some(f) if f >= u64::MAX as f64 && f.fract() == 0.0 => Ok(u64::MAX),
395        _ => Err(FormatError::corrupt(format!("`schema_version` is not a version number: {v}"))),
396    }
397}
398
399/// Just enough of a file to know what it is.
400#[derive(Default)]
401struct Probe {
402    format: Option<String>,
403    schema_version: Option<Value>,
404    has_payload: bool,
405}
406
407/// Reads a [`Probe`], skipping everything else (the payload included) without building it.
408struct ProbeSeed<'a> {
409    key: &'a str,
410}
411
412impl<'de> DeserializeSeed<'de> for ProbeSeed<'_> {
413    type Value = Probe;
414    fn deserialize<De: Deserializer<'de>>(self, de: De) -> Result<Probe, De::Error> {
415        de.deserialize_any(self)
416    }
417}
418
419impl<'de> Visitor<'de> for ProbeSeed<'_> {
420    type Value = Probe;
421    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        f.write_str("a document envelope object")
423    }
424    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Probe, A::Error> {
425        let mut p = Probe::default();
426        let (mut seen_format, mut seen_schema) = (false, false);
427        while let Some(k) = map.next_key::<String>()? {
428            if k == FORMAT {
429                if std::mem::replace(&mut seen_format, true) {
430                    return Err(serde::de::Error::duplicate_field(FORMAT));
431                }
432                p.format = map.next_value()?;
433            } else if k == SCHEMA {
434                if std::mem::replace(&mut seen_schema, true) {
435                    return Err(serde::de::Error::duplicate_field(SCHEMA));
436                }
437                p.schema_version = map.next_value()?;
438            } else {
439                p.has_payload |= k == self.key;
440                map.next_value::<IgnoredAny>()?;
441            }
442        }
443        Ok(p)
444    }
445    /// A bare array is no envelope, but may be a pre-envelope document: left to the legacy probe.
446    fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Probe, A::Error> {
447        while seq.next_element::<IgnoredAny>()?.is_some() {}
448        Ok(Probe::default())
449    }
450}
451
452struct EnvelopeOut<'a, D> {
453    format: &'a str,
454    schema_version: u64,
455    generator: &'a str,
456    key: &'a str,
457    doc: &'a D,
458    extras: &'a Map<String, Value>,
459    extras_after: bool,
460}
461
462impl<D: Serialize> Serialize for EnvelopeOut<'_, D> {
463    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
464        use serde::ser::SerializeMap;
465        let reserved = [FORMAT, SCHEMA, GENERATOR, self.key];
466        let mut m = s.serialize_map(None)?;
467        m.serialize_entry(FORMAT, self.format)?;
468        m.serialize_entry(SCHEMA, &self.schema_version)?;
469        m.serialize_entry(GENERATOR, self.generator)?;
470        let extras = self.extras.iter().filter(|(k, _)| !reserved.contains(&k.as_str()));
471        if self.extras_after {
472            m.serialize_entry(self.key, self.doc)?;
473            for (k, v) in extras {
474                m.serialize_entry(k, v)?;
475            }
476        } else {
477            for (k, v) in extras {
478                m.serialize_entry(k, v)?;
479            }
480            m.serialize_entry(self.key, self.doc)?;
481        }
482        m.end()
483    }
484}
485
486/// Reads a current-schema envelope in one pass: the payload straight into `D`, other keys kept.
487struct EnvelopeSeed<'a, D> {
488    key: &'a str,
489    _d: PhantomData<fn() -> D>,
490}
491
492type Parts<D> = (Option<D>, Map<String, Value>, Option<String>);
493
494impl<'de, D: DeserializeOwned> DeserializeSeed<'de> for EnvelopeSeed<'_, D> {
495    type Value = Parts<D>;
496    fn deserialize<De: Deserializer<'de>>(self, de: De) -> Result<Parts<D>, De::Error> {
497        de.deserialize_map(self)
498    }
499}
500
501impl<'de, D: DeserializeOwned> Visitor<'de> for EnvelopeSeed<'_, D> {
502    type Value = Parts<D>;
503    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504        f.write_str("a document envelope object")
505    }
506    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Parts<D>, A::Error> {
507        let (mut doc, mut extras, mut generator) = (None, Map::new(), None);
508        while let Some(k) = map.next_key::<String>()? {
509            if k == self.key {
510                if doc.is_some() {
511                    return Err(serde::de::Error::custom(format!("duplicate `{k}`")));
512                }
513                doc = Some(map.next_value::<D>()?);
514            } else if k == GENERATOR {
515                generator = map.next_value::<Option<String>>()?;
516            } else if k == FORMAT || k == SCHEMA {
517                map.next_value::<IgnoredAny>()?;
518            } else {
519                extras.insert(k, map.next_value::<Value>()?);
520            }
521        }
522        Ok((doc, extras, generator))
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use serde_json::json;
530
531    #[derive(Debug, PartialEq, Serialize, Deserialize)]
532    struct Doc {
533        title: String,
534        #[serde(default)]
535        tags: Vec<String>,
536    }
537
538    fn doc() -> Doc {
539        Doc { title: "hello".into(), tags: vec!["a".into()] }
540    }
541
542    fn v1_to_v2(v: Value) -> Result<Value, String> {
543        // v1 was the bare document with `name` instead of `title`.
544        let Value::Object(mut o) = v else { return Err("expected an object".into()) };
545        let name = o.remove("name").unwrap_or(Value::from(""));
546        o.insert("title".into(), name);
547        Ok(json!({ "format": "test.doc", "schema_version": 2, "generator": "v1", "document": o }))
548    }
549    fn v2_to_v3(v: Value) -> Result<Value, String> {
550        Ok(v)
551    }
552    fn failing(_: Value) -> Result<Value, String> {
553        Err("cannot".into())
554    }
555    fn not_object(_: Value) -> Result<Value, String> {
556        Ok(Value::from(3))
557    }
558
559    fn fmt() -> Format<Doc> {
560        Format::new("test.doc", "Test 1.0").migrations(vec![v1_to_v2, v2_to_v3]).legacy(|v| v.get("name").map(|_| 1))
561    }
562
563    #[test]
564    fn round_trip_current_schema() {
565        let f = fmt();
566        assert_eq!(f.schema_version(), 3);
567        let bytes = f.encode(&doc()).unwrap();
568        let text = String::from_utf8(bytes.clone()).unwrap();
569        assert!(text.starts_with("{\n  \"format\": \"test.doc\""), "{text}");
570        let l = f.decode(&bytes).unwrap();
571        assert_eq!(l.doc, doc());
572        assert_eq!(l.envelope, Envelope { format: "test.doc".into(), schema_version: 3, generator: Some("Test 1.0".into()) });
573        assert!(!l.migrated(f.schema_version()));
574        assert!(l.extras.is_empty());
575    }
576
577    #[test]
578    fn extras_round_trip_and_cannot_clobber_reserved_keys() {
579        let f = fmt().pretty(false);
580        let mut extras = Map::new();
581        extras.insert("view".into(), json!({"zoom": 2}));
582        extras.insert("schema_version".into(), json!(99));
583        extras.insert("document".into(), json!("evil"));
584        let bytes = f.encode_with(&doc(), &extras).unwrap();
585        let l = f.decode(&bytes).unwrap();
586        assert_eq!(l.doc, doc());
587        assert_eq!(l.envelope.schema_version, 3);
588        assert_eq!(l.extras.len(), 1);
589        assert_eq!(l.extras["view"], json!({"zoom": 2}));
590    }
591
592    #[test]
593    fn extras_after_payload_reencodes_byte_for_byte() {
594        // A file written by hand-rolled code: payload first, then extras.
595        #[derive(Serialize)]
596        struct Written<'a> {
597            format: &'a str,
598            schema_version: u64,
599            generator: &'a str,
600            document: &'a Doc,
601            view: Value,
602        }
603        let written = Written { format: "test.doc", schema_version: 3, generator: "Test 1.0", document: &doc(), view: json!({"zoom": 2}) };
604        for pretty in [true, false] {
605            let file = if pretty { serde_json::to_vec_pretty(&written) } else { serde_json::to_vec(&written) }.unwrap();
606            let f = fmt().pretty(pretty).extras_after_payload(true);
607            let l = f.decode(&file).unwrap();
608            assert_eq!(l.extras["view"], json!({"zoom": 2}));
609            assert_eq!(f.encode_with(&l.doc, &l.extras).unwrap(), file, "pretty: {pretty}");
610            // The default still writes extras first, and both orders read back.
611            let before = fmt().pretty(pretty).encode_with(&l.doc, &l.extras).unwrap();
612            assert_ne!(before, file);
613            assert_eq!(f.decode(&before).unwrap().extras, l.extras);
614        }
615        // Reserved keys still can't be clobbered from the extras.
616        let mut extras = Map::new();
617        extras.insert("document".into(), json!("evil"));
618        extras.insert("format".into(), json!("other"));
619        let f = fmt().extras_after_payload(true);
620        let l = f.decode(&f.encode_with(&doc(), &extras).unwrap()).unwrap();
621        assert_eq!(l.doc, doc());
622        assert!(l.extras.is_empty());
623    }
624
625    #[test]
626    fn untagged_envelopes_only_when_accepted() {
627        let current = br#"{"schema_version":3,"generator":"Early","document":{"title":"t"},"view":1}"#;
628        let older = br#"{"schema_version":2,"document":{"title":"o"}}"#;
629        let refuse = fmt();
630        for b in [&current[..], &older[..]] {
631            let e = refuse.decode(b).unwrap_err();
632            assert!(matches!(&e, FormatError::NotThisFormat { why, detail: None, .. } if why.contains("format")), "{e}");
633        }
634        let accept = fmt().accept_untagged(true);
635        let l = accept.decode(current).unwrap();
636        assert_eq!(l.doc.title, "t");
637        assert_eq!(l.envelope, Envelope { format: "test.doc".into(), schema_version: 3, generator: Some("Early".into()) });
638        assert_eq!(l.extras["view"], json!(1));
639        let l = accept.decode(older).unwrap();
640        assert_eq!(l.doc.title, "o");
641        assert_eq!(l.envelope.format, "test.doc");
642        assert!(l.migrated(accept.schema_version()));
643        // Not this format: no payload key, another format's tag, or a newer schema.
644        let e = accept.decode(br#"{"schema_version":3,"project":{}}"#).unwrap_err();
645        assert!(matches!(e, FormatError::NotThisFormat { .. }), "{e}");
646        let e = accept.decode(br#"{"format":"other.doc","schema_version":3,"document":{}}"#).unwrap_err();
647        assert!(matches!(e, FormatError::WrongFormat { .. }), "{e}");
648        let e = accept.decode(br#"{"schema_version":4,"document":{}}"#).unwrap_err();
649        assert!(matches!(e, FormatError::TooNew { found: 4, .. }), "{e}");
650        let e = accept.decode(br#"{"format":null,"schema_version":3,"document":{"title":"n"}}"#);
651        assert_eq!(e.unwrap().doc.title, "n", "a null tag is no tag");
652    }
653
654    #[test]
655    fn errors_keep_the_serde_error() {
656        let f = fmt();
657        // Valid JSON of the wrong shape: refused, with serde's message and position.
658        let bad = b"{\n  \"format\": 7,\n  \"schema_version\": 3\n}";
659        let e = f.decode(bad).unwrap_err();
660        let j = e.json().cloned().unwrap();
661        assert_eq!((j.line, j.column), (2, 13), "{j:?}");
662        assert!(!j.syntax);
663        assert!(j.message.contains("invalid type: integer `7`") && j.message.ends_with("at line 2 column 13"), "{}", j.message);
664        assert!(matches!(&e, FormatError::NotThisFormat { why, .. } if *why == j.message));
665        let e = f.decode(b"[1, 2]").unwrap_err();
666        assert!(e.json().is_none(), "not legacy, not a parse error: {e}");
667        let e = f.decode(br#"{"format":"test.doc","format":"test.doc","schema_version":3}"#).unwrap_err();
668        assert!(e.json().is_some_and(|j| j.message.contains("duplicate field `format`")), "{e}");
669        // Malformed JSON: `Corrupt` with serde's message verbatim.
670        let broken = b"{\n  \"format\": \"test.doc\",\n  \"schema_version\": 3,,\n}";
671        let expected = serde_json::from_slice::<Value>(broken).unwrap_err().to_string();
672        let e = f.decode(broken).unwrap_err();
673        assert!(matches!(&e, FormatError::Corrupt { message, .. } if *message == expected), "{e}");
674        assert!(expected.ends_with("at line 3 column 23"), "{expected}");
675        let j = e.json().expect("structured detail");
676        assert_eq!((j.line, j.column, j.syntax), (3, 23, true));
677        let e = f.decode(br#"{"format":"test.doc","schema_version":3,"document":{}} trailing"#).unwrap_err();
678        assert!(matches!(e, FormatError::Corrupt { .. }), "{e}");
679    }
680
681    #[test]
682    fn migrates_legacy_and_older_envelopes() {
683        let f = fmt();
684        let l = f.decode(br#"{"name":"old","tags":["x"]}"#).unwrap();
685        assert_eq!(l.doc, Doc { title: "old".into(), tags: vec!["x".into()] });
686        assert_eq!(l.envelope.schema_version, 1);
687        assert!(l.migrated(f.schema_version()));
688        let l = f.decode(br#"{"format":"test.doc","schema_version":2,"generator":"Old","document":{"title":"t"},"view":1}"#).unwrap();
689        assert_eq!(l.doc.title, "t");
690        assert_eq!(l.envelope.generator.as_deref(), Some("Old"));
691        assert_eq!(l.extras["view"], json!(1));
692    }
693
694    #[test]
695    fn refuses_newer_and_other_formats() {
696        let f = fmt();
697        let e = f.decode(br#"{"format":"test.doc","schema_version":4,"document":{}}"#).unwrap_err();
698        assert!(matches!(e, FormatError::TooNew { found: 4, supported: 3 }), "{e}");
699        let e = f.decode(br#"{"format":"test.doc","schema_version":18446744073709551615,"document":{}}"#).unwrap_err();
700        assert!(matches!(e, FormatError::TooNew { found: u64::MAX, .. }), "{e}");
701        let e = f.decode(br#"{"format":"test.doc","schema_version":1e300,"document":{}}"#).unwrap_err();
702        assert!(matches!(e, FormatError::TooNew { found: u64::MAX, .. }), "{e}");
703        let e = f.decode(br#"{"format":"test.doc","schema_version":999999999999999999999999999,"document":{}}"#).unwrap_err();
704        assert!(matches!(e, FormatError::TooNew { .. }), "{e}");
705        let e = f.decode(br#"{"format":"other.doc","schema_version":3,"document":{}}"#).unwrap_err();
706        assert!(matches!(e, FormatError::WrongFormat { .. }), "{e}");
707        let e = f.decode(br#"{"schema_version":3,"document":{}}"#).unwrap_err();
708        assert!(matches!(e, FormatError::NotThisFormat { .. }), "{e}");
709        let e = f.decode(br#"{"title":"no envelope, not legacy"}"#).unwrap_err();
710        assert!(matches!(e, FormatError::NotThisFormat { .. }), "{e}");
711        let e = Format::<Doc>::new("test.doc", "t").oldest(2).decode(br#"{"format":"test.doc","schema_version":1,"document":{}}"#).unwrap_err();
712        assert!(matches!(e, FormatError::TooOld { found: 1, oldest: 2 }), "{e}");
713    }
714
715    #[test]
716    fn hostile_inputs_are_errors_not_crashes() {
717        let f = fmt();
718        let cases: &[&[u8]] = &[
719            b"",
720            b"{",
721            b"\xff\xfe",
722            b"[]",
723            b"null",
724            b"42",
725            br#"{"format":"test.doc","schema_version":-1,"document":{}}"#,
726            br#"{"format":"test.doc","schema_version":0,"document":{}}"#,
727            br#"{"format":"test.doc","schema_version":2.5,"document":{}}"#,
728            br#"{"format":"test.doc","schema_version":"3","document":{}}"#,
729            br#"{"format":"test.doc","schema_version":3}"#,
730            br#"{"format":"test.doc","schema_version":3,"document":{"title":5}}"#,
731            br#"{"format":"test.doc","schema_version":3,"document":{"title":"a"},"document":{"title":"b"}}"#,
732            br#"{"format":"test.doc","schema_version":2,"document":[]}"#,
733            br#"{"format":7,"schema_version":3,"document":{}}"#,
734            br#"{"format":"test.doc","schema_version":3,"generator":5,"document":{"title":"a"}}"#,
735        ];
736        for c in cases {
737            assert!(f.decode(c).is_err(), "{}", String::from_utf8_lossy(c));
738        }
739        // Truncations of a valid file at every length.
740        let good = f.encode(&doc()).unwrap();
741        for n in 0..good.len() {
742            assert!(f.decode(&good[..n]).is_err(), "truncated at {n}");
743        }
744        // Deep nesting hits the parser's recursion limit, not the stack.
745        let deep =
746            format!(r#"{{"format":"test.doc","schema_version":3,"document":{{"title":"x","tags":{}{}}}}}"#, "[".repeat(100_000), "]".repeat(100_000));
747        assert!(f.decode(deep.as_bytes()).is_err());
748        let small = Format::<Doc>::new("test.doc", "t").max_bytes(10);
749        assert!(matches!(small.decode(&good), Err(FormatError::Corrupt { .. })));
750    }
751
752    #[test]
753    fn migration_errors() {
754        let f = Format::<Doc>::new("test.doc", "t").migrations(vec![failing]);
755        let e = f.decode(br#"{"format":"test.doc","schema_version":1,"document":{"title":"a"}}"#).unwrap_err();
756        assert!(matches!(e, FormatError::Migration { from: 1, .. }), "{e}");
757        let f = Format::<Doc>::new("test.doc", "t").migrations(vec![not_object]);
758        let e = f.decode(br#"{"format":"test.doc","schema_version":1,"document":{"title":"a"}}"#).unwrap_err();
759        assert!(matches!(e, FormatError::Migration { from: 1, .. }), "{e}");
760        assert!(matches!(migrate(&[], json!({}), 0), Err(FormatError::TooOld { .. })));
761        assert!(matches!(migrate(&[], json!({}), 5), Err(FormatError::TooNew { .. })));
762        assert_eq!(migrate(&[v2_to_v3], json!({}), 1).unwrap()["schema_version"], 2);
763    }
764
765    #[test]
766    fn save_and_load_files() {
767        let tmp = tempfile::tempdir().unwrap();
768        let dir = tmp.path();
769        let p = dir.join("a.testdoc");
770        let f = fmt();
771        f.save(&p, &doc()).unwrap();
772        assert_eq!(f.load(&p).unwrap().doc, doc());
773        assert!(matches!(f.load(&dir.join("missing")), Err(FormatError::Io(_))));
774        assert!(matches!(fmt().max_bytes(5).load(&p), Err(FormatError::Corrupt { .. })));
775    }
776}