Skip to main content

n3_lib/
records.rs

1//! Editable wire records driven by audited declarative binary layouts.
2//!
3//! Float fields contain unsigned IEEE-754 storage bits, not JSON floats.
4//! Strings and opaque byte ranges contain lowercase hexadecimal strings.
5//! This representation preserves nonfinite values and undecodable text.
6
7use crate::binary::{Error, Reader, Result, Writer};
8use serde_json::{Map, Value, json};
9use std::sync::OnceLock;
10
11/// Supported storage-record format names, excluding the leading dot.
12pub(crate) const RECORD_FORMATS: &[&str] = &[
13    "n3pmesh", "n3cskin", "n3cskins", "n3cpart", "n3cplug", "n3shape", "n3chr", "n3joint",
14    "n3anim", "n3fxplug", "n3sky", "fxb", "gtd", "opd", "uif",
15];
16
17/// Editable JSON-compatible storage record with raw float bits and hex strings.
18pub type Record = Map<String, Value>;
19
20/// Resource budgets applied before traversing untrusted records.
21#[derive(Clone, Debug)]
22pub struct Limits {
23    /// Maximum input or output bytes.
24    pub bytes: usize,
25    /// Maximum traversal nodes, including scalar values.
26    pub nodes: usize,
27    /// Maximum nested traversal depth.
28    pub depth: usize,
29    /// Explicit nonserialized layout parameters, such as OPD gridCell and UIF fileVersion.
30    pub parameters: std::collections::BTreeMap<String, i64>,
31}
32impl Default for Limits {
33    fn default() -> Self {
34        Self {
35            bytes: 256 * 1024 * 1024,
36            nodes: 8_000_000,
37            depth: 128,
38            parameters: Default::default(),
39        }
40    }
41}
42
43fn error(message: impl Into<String>) -> Error {
44    Error {
45        offset: 0,
46        message: message.into(),
47    }
48}
49fn definitions() -> &'static Value {
50    static SCHEMAS: OnceLock<Value> = OnceLock::new();
51    SCHEMAS.get_or_init(|| {
52        serde_json::from_str(include_str!("../layouts.json")).expect("Valid bundled format layouts")
53    })
54}
55
56/// Encode original bytes as lowercase hexadecimal.
57pub fn hex(bytes: &[u8]) -> String {
58    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
59}
60/// Decode validated hexadecimal without lossy string substitutions.
61pub fn unhex(value: &str) -> Result<Vec<u8>> {
62    if !value.len().is_multiple_of(2) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
63        return Err(error("Invalid hex byte string"));
64    }
65    value
66        .as_bytes()
67        .as_chunks::<2>()
68        .0
69        .iter()
70        .map(|pair| {
71            let digit = |byte: u8| match byte {
72                b'0'..=b'9' => byte - b'0',
73                b'a'..=b'f' => byte - b'a' + 10,
74                _ => byte - b'A' + 10,
75            };
76            Ok(digit(pair[0]) * 16 + digit(pair[1]))
77        })
78        .collect()
79}
80/// Store UTF-8 text without changing the wire string representation.
81pub fn utf8(value: &str) -> String {
82    hex(value.as_bytes())
83}
84
85fn expr(value: &Value, context: &Value) -> Result<i64> {
86    if let Some(number) = value.as_i64() {
87        return Ok(number);
88    }
89    if let Some(name) = value.as_str() {
90        return context[name]
91            .as_i64()
92            .ok_or_else(|| error(format!("Missing numeric field {name}")));
93    }
94    if let Some(parts) = value["product"].as_array() {
95        return parts.iter().try_fold(1_i64, |product, part| {
96            product
97                .checked_mul(expr(part, context)?)
98                .ok_or_else(|| error("Size overflow"))
99        });
100    }
101    if let Some(parts) = value["gt"].as_array() {
102        return Ok(i64::from(
103            expr(&parts[0], context)? > expr(&parts[1], context)?,
104        ));
105    }
106    if let Some(parts) = value["eq"].as_array() {
107        return Ok(i64::from(
108            expr(&parts[0], context)? == expr(&parts[1], context)?,
109        ));
110    }
111    if let Some(parts) = value["minus"].as_array() {
112        return expr(&parts[0], context)?
113            .checked_sub(expr(&parts[1], context)?)
114            .ok_or_else(|| error("Expression overflow"));
115    }
116    if let Some(parts) = value["divide"].as_array() {
117        let divisor = expr(&parts[1], context)?;
118        if divisor <= 0 {
119            return Err(error("Invalid layout divisor"));
120        }
121        return Ok(expr(&parts[0], context)?.div_euclid(divisor));
122    }
123    if let Some(parts) = value["mask"].as_array() {
124        return Ok(expr(&parts[0], context)? & expr(&parts[1], context)?);
125    }
126    if let Some(parts) = value["and"].as_array() {
127        for part in parts {
128            if expr(part, context)? == 0 {
129                return Ok(0);
130            }
131        }
132        return Ok(1);
133    }
134    if let Some(parts) = value["or"].as_array() {
135        for part in parts {
136            if expr(part, context)? != 0 {
137                return Ok(1);
138            }
139        }
140        return Ok(0);
141    }
142    if !value["float"].is_null() {
143        let number = f32::from_bits(
144            u32::try_from(expr(&value["float"], context)?)
145                .map_err(|_| error("Invalid f32 bits"))?,
146        );
147        if !number.is_finite() {
148            return Err(error("Nonfinite layout dimension"));
149        }
150        return Ok(number.floor() as i64);
151    }
152    if let Some(name) = value["length"].as_str() {
153        return context[name]
154            .as_str()
155            .map(|text| text.len() as i64 / 2)
156            .ok_or_else(|| error("Expected hex string"));
157    }
158    if let Some(parts) = value["choose"].as_array() {
159        return expr(
160            &parts[if expr(&parts[0], context)? != 0 { 1 } else { 2 }],
161            context,
162        );
163    }
164    if let Some(parts) = value["select"].as_array() {
165        let tag = expr(&parts[0], context)?;
166        if !(0..=1).contains(&tag) {
167            return Err(error("Unsupported animation key type"));
168        }
169        return expr(&parts[tag as usize + 1], context);
170    }
171    Err(error("Invalid layout expression"))
172}
173fn count(value: &Value, context: &Value) -> Result<usize> {
174    let value = expr(value, context)?;
175    if !(0..=8_000_000).contains(&value) {
176        return Err(error("Invalid array count"));
177    }
178    Ok(value as usize)
179}
180fn minimum(schema: &Value) -> usize {
181    if let Some(name) = schema.as_str() {
182        if name == "ntf" {
183            return 24;
184        }
185        if !definitions()[name].is_null() {
186            return minimum(&definitions()[name]);
187        }
188        return match name {
189            "string" | "f32" | "u32" | "i32" => 4,
190            "u16" | "i16" => 2,
191            _ => 1,
192        };
193    }
194    if schema["kind"] == "struct" {
195        return schema["fields"].as_array().map_or(0, |fields| {
196            fields
197                .iter()
198                .filter(|field| field["when"].is_null() && field["optional"] != true)
199                .map(|field| minimum(&field["type"]))
200                .sum()
201        });
202    }
203    if schema["kind"] == "choice" {
204        return minimum(&schema["yes"]).min(minimum(&schema["no"]));
205    }
206    schema["count"].as_u64().map_or(0, |count| {
207        count as usize
208            * if schema["kind"] == "bytes" {
209                1
210            } else {
211                minimum(&schema["item"])
212            }
213    })
214}
215enum Io<'a> {
216    Read(Reader<'a>),
217    Write(Writer),
218}
219struct State<'a> {
220    io: Io<'a>,
221    nodes: usize,
222    depth: usize,
223    parameters: std::collections::BTreeMap<String, i64>,
224}
225fn scope(parent: &Value, local: &Value) -> Value {
226    let mut scope = Map::new();
227    for value in [parent, local] {
228        if let Some(fields) = value.as_object() {
229            for (name, value) in fields {
230                if value.is_number() || value.is_string() {
231                    scope.insert(name.clone(), value.clone());
232                }
233            }
234        }
235    }
236    Value::Object(scope)
237}
238impl State<'_> {
239    fn reading(&self) -> bool {
240        matches!(self.io, Io::Read(_))
241    }
242    fn traverse(
243        &mut self,
244        schema: &Value,
245        context: &Value,
246        input: &Value,
247        depth: usize,
248    ) -> Result<Value> {
249        if self.nodes == 0 || depth > self.depth {
250            return Err(error("Record complexity limit exceeded"));
251        }
252        self.nodes -= 1;
253        if let Some(name) = schema.as_str() {
254            if name == "ntf" {
255                return match &mut self.io {
256                    Io::Read(reader) => {
257                        serde_json::to_value(crate::textures::read_texture(reader)?)
258                            .map_err(|_| error("Texture serialization failed"))
259                    }
260                    Io::Write(writer) => {
261                        let texture = serde_json::from_value(input.clone())
262                            .map_err(|_| error("Invalid texture record"))?;
263                        crate::textures::append_texture(writer, &texture)?;
264                        Ok(input.clone())
265                    }
266                };
267            }
268            if !definitions()[name].is_null() {
269                return self.traverse(&definitions()[name], context, input, depth + 1);
270            }
271            return match &mut self.io {
272                Io::Read(reader) => Ok(match name {
273                    "string" => json!(hex(&reader.string(1_048_576)?)),
274                    "u8" => json!(reader.u8()?),
275                    "i8" => json!(reader.i8()?),
276                    "u16" => json!(reader.u16()?),
277                    "i16" => json!(reader.i16()?),
278                    "u32" | "f32" => json!(reader.u32()?),
279                    "i32" => json!(reader.i32()?),
280                    _ => return Err(error("Unknown primitive")),
281                }),
282                Io::Write(writer) => {
283                    if name == "string" {
284                        writer.string(&unhex(
285                            input.as_str().ok_or_else(|| error("Expected hex string"))?,
286                        )?)?;
287                    } else {
288                        let value = input
289                            .as_i64()
290                            .ok_or_else(|| error("Expected integer wire value"))?;
291                        let invalid = |_| error(format!("Value outside {name} range"));
292                        match name {
293                            "u8" => writer.u8(u8::try_from(value).map_err(invalid)?)?,
294                            "i8" => writer.i8(i8::try_from(value).map_err(invalid)?)?,
295                            "u16" => writer.u16(u16::try_from(value).map_err(invalid)?)?,
296                            "i16" => writer.i16(i16::try_from(value).map_err(invalid)?)?,
297                            "u32" | "f32" => writer.u32(u32::try_from(value).map_err(invalid)?)?,
298                            "i32" => writer.i32(i32::try_from(value).map_err(invalid)?)?,
299                            _ => return Err(error("Unknown primitive")),
300                        }
301                    }
302                    Ok(input.clone())
303                }
304            };
305        }
306        if schema["kind"] == "choice" {
307            return self.traverse(
308                &schema[if expr(&schema["condition"], context)? != 0 {
309                    "yes"
310                } else {
311                    "no"
312                }],
313                context,
314                input,
315                depth + 1,
316            );
317        }
318        if schema["kind"] == "parameter" {
319            let name = schema["name"].as_str().unwrap();
320            let value = if self.reading() {
321                self.parameters
322                    .get(name)
323                    .copied()
324                    .unwrap_or(schema["default"].as_i64().unwrap())
325            } else {
326                input
327                    .as_i64()
328                    .ok_or_else(|| error("Missing layout parameter"))?
329            };
330            if !(0..=65536).contains(&value) {
331                return Err(error("Invalid layout parameter"));
332            }
333            return Ok(json!(value));
334        }
335        if schema["kind"] == "tail" {
336            return match &mut self.io {
337                Io::Read(reader) => Ok(json!(hex(reader.raw(reader.remaining())?))),
338                Io::Write(writer) => {
339                    writer.raw(&unhex(
340                        input
341                            .as_str()
342                            .ok_or_else(|| error("Expected tail hex bytes"))?,
343                    )?)?;
344                    Ok(input.clone())
345                }
346            };
347        }
348        if schema["kind"] == "struct" {
349            let fields = schema["fields"]
350                .as_array()
351                .ok_or_else(|| error("Missing layout fields"))?;
352            let mut result = if self.reading() {
353                json!({})
354            } else {
355                if !input.is_object() {
356                    return Err(error("Expected record"));
357                }
358                input.clone()
359            };
360            if !self.reading() {
361                for field in fields {
362                    if let Some(array) = field["sizeOf"].as_str() {
363                        if !field["when"].is_null()
364                            && expr(&field["when"], &scope(context, &result))? == 0
365                        {
366                            continue;
367                        }
368                        if result.get(array).is_none()
369                            && (field["optional"] == true
370                                || result[field["name"].as_str().unwrap()] == 0)
371                        {
372                            continue;
373                        }
374                        let length = result[array]
375                            .as_array()
376                            .ok_or_else(|| error(format!("Expected array {array}")))?
377                            .len();
378                        result[field["name"].as_str().unwrap()] = json!(length);
379                    }
380                }
381            }
382            let mut omitted = false;
383            for field in fields {
384                let name = field["name"].as_str().unwrap();
385                if !field["when"].is_null() && expr(&field["when"], &scope(context, &result))? == 0
386                {
387                    continue;
388                }
389                let absent = match &self.io {
390                    Io::Read(reader) => reader.remaining() == 0,
391                    Io::Write(_) => result.get(name).is_none(),
392                };
393                if field["optional"] == true && absent {
394                    omitted = true;
395                    continue;
396                }
397                if omitted {
398                    return Err(error("Optional tail fields must form a contiguous prefix"));
399                }
400                result[name] = self.traverse(
401                    &field["type"],
402                    &scope(context, &result),
403                    &result[name],
404                    depth + 1,
405                )?;
406            }
407            return Ok(result);
408        }
409        let length = count(&schema["count"], context)?;
410        if schema["kind"] == "bytes" {
411            return match &mut self.io {
412                Io::Read(reader) => Ok(json!(hex(reader.raw(length)?))),
413                Io::Write(writer) => {
414                    let bytes = unhex(
415                        input
416                            .as_str()
417                            .ok_or_else(|| error("Expected opaque hex bytes"))?,
418                    )?;
419                    if bytes.len() != length {
420                        return Err(error("Opaque byte count mismatch"));
421                    }
422                    writer.raw(&bytes)?;
423                    Ok(input.clone())
424                }
425            };
426        }
427        if length > self.nodes {
428            return Err(error("Array exceeds node budget"));
429        }
430        if let Io::Read(reader) = &self.io
431            && length.saturating_mul(minimum(&schema["item"]).max(1)) > reader.remaining()
432        {
433            return Err(error("Array exceeds available bytes"));
434        }
435        if !self.reading() && input.as_array().is_none_or(|array| array.len() != length) {
436            return Err(error("Array count mismatch"));
437        }
438        let mut values = Vec::new();
439        values
440            .try_reserve(length)
441            .map_err(|_| error("Array allocation failed"))?;
442        for index in 0..length {
443            values.push(self.traverse(&schema["item"], context, &input[index], depth + 1)?);
444        }
445        Ok(Value::Array(values))
446    }
447}
448
449/// Parse a complete known-layout record under default safety budgets.
450pub(crate) fn parse_record(format: &str, bytes: &[u8]) -> Result<Record> {
451    parse_record_with_limits(format, bytes, Limits::default())
452}
453/// Parse a record with explicit byte, node and depth budgets.
454pub(crate) fn parse_record_with_limits(
455    format: &str,
456    bytes: &[u8],
457    limits: Limits,
458) -> Result<Record> {
459    if !RECORD_FORMATS.contains(&format) {
460        return Err(error(format!("Unknown record format {format}")));
461    }
462    if bytes.len() > limits.bytes {
463        return Err(error("Input byte budget exceeded"));
464    }
465    let mut state = State {
466        io: Io::Read(Reader::new(bytes)),
467        nodes: limits.nodes,
468        depth: limits.depth,
469        parameters: limits.parameters,
470    };
471    let record = state.traverse(&json!(format), &json!({}), &Value::Null, 0)?;
472    if let Io::Read(reader) = state.io {
473        reader.end()?;
474    }
475    validate(format, &record)?;
476    Ok(record.as_object().unwrap().clone())
477}
478/// Serialize a new or edited record, deriving count prefixes from their arrays.
479pub(crate) fn write_record(format: &str, record: &Record) -> Result<Vec<u8>> {
480    write_record_with_limits(format, record, Limits::default())
481}
482/// Serialize and validate edited records under explicit safety budgets.
483pub(crate) fn write_record_with_limits(
484    format: &str,
485    record: &Record,
486    mut limits: Limits,
487) -> Result<Vec<u8>> {
488    if !RECORD_FORMATS.contains(&format) {
489        return Err(error("Unknown record format"));
490    }
491    let mut state = State {
492        io: Io::Write(Writer::new(limits.bytes)),
493        nodes: limits.nodes,
494        depth: limits.depth,
495        parameters: limits.parameters.clone(),
496    };
497    state.traverse(
498        &json!(format),
499        &json!({}),
500        &Value::Object(record.clone()),
501        0,
502    )?;
503    let Io::Write(writer) = state.io else {
504        unreachable!()
505    };
506    let bytes = writer.finish();
507    for name in ["gridCell", "fileVersion", "spacingPresent", "type"] {
508        if let Some(value) = record.get(name).and_then(Value::as_i64) {
509            limits.parameters.insert(name.into(), value);
510        }
511    }
512    parse_record_with_limits(format, &bytes, limits)?;
513    Ok(bytes)
514}
515fn validate(format: &str, record: &Value) -> Result<()> {
516    if format == "fxb" {
517        if !matches!(record["version"].as_i64(), Some(0..=3)) {
518            return Err(error("Unsupported FXB version"));
519        }
520        for slot in record["slots"].as_array().unwrap() {
521            if !matches!(slot["type"].as_i64(), Some(0..=4)) {
522                return Err(error("Unsupported FXB part type"));
523            }
524            if slot["type"] != 0
525                && (!matches!(slot["version"].as_i64(), Some(0..=7))
526                    || !matches!(slot["baseVersion"].as_i64(), Some(0..=2)))
527            {
528                return Err(error("Unsupported FXB part version"));
529            }
530        }
531        if record["version"] == 3
532            && !matches!(record["trailer"].as_str().unwrap_or("").len(), 88 | 90)
533        {
534            return Err(error("FXB v3 requires its observed 44/45-byte trailer"));
535        }
536    }
537    if format == "gtd"
538        && (!matches!(record["version"].as_i64(), Some(0..=2))
539            || !matches!(record["mapSize"].as_i64(), Some(1..=4097)))
540    {
541        return Err(error("Invalid GTD version or dimensions"));
542    }
543    if format == "opd" && !matches!(record["gridCell"].as_i64(), Some(1..=1024)) {
544        return Err(error("Invalid OPD grid cell"));
545    }
546    if format == "n3pmesh" {
547        let vertices = record["vertexCount"].as_i64().unwrap_or(-1);
548        let indices = record["indices"].as_array().unwrap();
549        if !(1..=2_000_000).contains(&vertices)
550            || indices.is_empty()
551            || !indices.len().is_multiple_of(3)
552            || indices
553                .iter()
554                .any(|index| index.as_i64().unwrap_or(-1) >= vertices)
555        {
556            return Err(error("Invalid mesh vertices or indices"));
557        }
558        if !(0..=vertices).contains(&record["minVertexCount"].as_i64().unwrap_or(-1))
559            || !(0..=indices.len() as i64).contains(&record["minIndexCount"].as_i64().unwrap_or(-1))
560        {
561            return Err(error("Invalid minimum mesh counts"));
562        }
563    }
564    if format == "n3cskin" {
565        let vertices = record["vertexCount"].as_i64().unwrap_or(-1);
566        let uvs = record["uvCount"].as_i64().unwrap_or(-1);
567        for (field, maximum) in [("vertexIndices", vertices), ("uvIndices", uvs)] {
568            if record[field]
569                .as_array()
570                .unwrap()
571                .iter()
572                .any(|index| index.as_i64().unwrap_or(-1) >= maximum)
573            {
574                return Err(error("Skin index out of range"));
575            }
576        }
577        let faces = record["faceCount"].as_i64().unwrap_or(-1);
578        if (vertices == 0 || faces == 0) && (vertices != 0 || faces != 0 || uvs != 0) {
579            return Err(error("Inconsistent empty skin"));
580        }
581    }
582    if format == "n3cskins" {
583        for lod in record["lods"].as_array().unwrap() {
584            validate("n3cskin", lod)?;
585        }
586    }
587    Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    #[test]
594    fn sky_roundtrip_and_edit() -> Result<()> {
595        let value = json!({"suns":["00ff","", ""],"clouds":["","","","","",""],"moon":"", "dayChanges":[{"name":"202000", "type":0,"when":500,"param1":4294967295_u32,"param2":0,"howLong":2141266757_u32}]});
596        let bytes = write_record("n3sky", value.as_object().unwrap())?;
597        let mut record = parse_record("n3sky", &bytes)?;
598        assert_eq!(write_record("n3sky", &record)?, bytes);
599        record.insert("moon".into(), json!(utf8("new moon")));
600        assert_eq!(
601            parse_record("n3sky", &write_record("n3sky", &record)?)?["moon"],
602            utf8("new moon")
603        );
604        assert!(parse_record("n3sky", &bytes[..bytes.len() - 1]).is_err());
605        Ok(())
606    }
607}