Files
OneCAD/crates/doc/src/metadata.rs
T
Lasse Lian a4a8b48567 Design properties: project, author and licence in the design, its exports and inspect_design (#1015) (#1189)
A design carries metadata (format 21): project, part number, revision, author, organization, description and license, plus other tools' data under reverse-DNS keys, kept as written. Edited in the Design Properties window (Document Settings), set by document.metadata.set, reported by inspect_design, and written into STEP, IGES and 3MF where the format has a place for them. Translated, with tests.
2026-10-14 18:25:35 -04:00

264 lines
11 KiB
Rust

//! Design properties (#1015): the design's optional top-level `metadata` object.
//!
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
//! revision, author, organization, description, licence) and keeps every other key exactly as
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
//! key each (`com.example.tool`), holding an object.
//!
//! Reading never validates: a well-known key holding something other than text is kept as it
//! is and reads as absent, so a file another tool wrote always opens. Writing through
//! [`Metadata::set`] (the `document.metadata.set` command) checks the key, the value's kind and
//! its size, so nothing SolidCraft writes is malformed.
//!
//! The map is stored as is: typed fields would refuse a design another tool had written
//! `"author": 42` into. Everything under `metadata` is informational, never geometry, so keeping
//! unknown keys can't leave stale model data behind (unlike keeping unknown top-level fields).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{DocError, Result};
pub const PROJECT: &str = "project";
pub const PART_NUMBER: &str = "part_number";
pub const REVISION: &str = "revision";
pub const AUTHOR: &str = "author";
pub const ORGANIZATION: &str = "organization";
pub const DESCRIPTION: &str = "description";
pub const LICENSE: &str = "license";
/// SolidCraft's own keys, in the order the Design Properties dialog shows them. Every other
/// unprefixed key is reserved for SolidCraft too.
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
/// Longest key, in bytes.
pub const MAX_KEY_BYTES: usize = 128;
/// Longest well-known text value, in bytes.
pub const MAX_TEXT_BYTES: usize = 4096;
/// Largest namespaced value, as compact JSON.
pub const MAX_VALUE_BYTES: usize = 64 * 1024;
/// Deepest nesting of a namespaced value.
pub const MAX_VALUE_DEPTH: usize = 32;
/// Most keys, and largest object as compact JSON, that a change may grow the metadata to.
pub const MAX_KEYS: usize = 256;
pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
/// The design's `metadata` object.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Metadata(pub Map<String, Value>);
/// A key SolidCraft reads as a design property.
pub fn is_well_known(key: &str) -> bool {
WELL_KNOWN.contains(&key)
}
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
/// ASCII letters, digits, `-` and `_` (`com.example.tool`, `org.printfarm`).
pub fn is_namespaced(key: &str) -> bool {
key.len() <= MAX_KEY_BYTES
&& key.split('.').count() >= 2
&& key.split('.').all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
}
fn depth(v: &Value) -> usize {
// Iterative, so a hostile value can't overflow the stack here.
let mut deepest = 0;
let mut stack = vec![(v, 1usize)];
while let Some((v, d)) = stack.pop() {
deepest = deepest.max(d);
if d > MAX_VALUE_DEPTH {
break;
}
match v {
Value::Array(a) => stack.extend(a.iter().map(|x| (x, d + 1))),
Value::Object(m) => stack.extend(m.values().map(|x| (x, d + 1))),
_ => {}
}
}
deepest
}
fn json_len(v: &impl Serialize) -> usize {
serde_json::to_vec(v).map_or(usize::MAX, |b| b.len())
}
fn invalid(msg: String) -> DocError {
DocError::Invalid(msg)
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.0.get(key)
}
/// A text value: `None` when the key is missing, empty or holds something other than text.
pub fn text(&self, key: &str) -> Option<&str> {
self.0.get(key)?.as_str().filter(|s| !s.is_empty())
}
pub fn project(&self) -> Option<&str> {
self.text(PROJECT)
}
pub fn part_number(&self) -> Option<&str> {
self.text(PART_NUMBER)
}
pub fn revision(&self) -> Option<&str> {
self.text(REVISION)
}
pub fn author(&self) -> Option<&str> {
self.text(AUTHOR)
}
pub fn organization(&self) -> Option<&str> {
self.text(ORGANIZATION)
}
pub fn description(&self) -> Option<&str> {
self.text(DESCRIPTION)
}
pub fn license(&self) -> Option<&str> {
self.text(LICENSE)
}
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
/// kept as they are.
pub fn others(&self) -> impl Iterator<Item = (&String, &Value)> {
self.0.iter().filter(|(k, _)| !is_well_known(k))
}
/// Set `key` to `value` after checking both; `null` (or empty text for a well-known key)
/// removes it. Returns whether the metadata changed.
pub fn set(&mut self, key: &str, value: Value) -> Result<bool> {
let value = check(key, value)?;
let Some(value) = value else { return Ok(self.remove(key)) };
if self.0.get(key) == Some(&value) {
return Ok(false);
}
let (keys, bytes) = (self.0.len(), json_len(&self.0));
let old = self.0.insert(key.to_string(), value);
// Growing past the limits is refused; a file already past them can still shrink.
let (new_keys, new_bytes) = (self.0.len(), json_len(&self.0));
if (new_keys > MAX_KEYS && new_keys > keys) || (new_bytes > MAX_TOTAL_BYTES && new_bytes > bytes) {
match old {
Some(old) => self.0.insert(key.to_string(), old),
None => self.0.remove(key),
};
return Err(invalid(format!("design properties are limited to {MAX_KEYS} keys and {} KiB", MAX_TOTAL_BYTES / 1024)));
}
Ok(true)
}
/// Remove `key`, whatever it holds. Returns whether it was there.
pub fn remove(&mut self, key: &str) -> bool {
self.0.remove(key).is_some()
}
}
/// `value` as `key` may hold it (`None`: remove the key), or why it can't.
fn check(key: &str, value: Value) -> Result<Option<Value>> {
if is_well_known(key) {
return match value {
Value::Null => Ok(None),
Value::String(s) if s.is_empty() => Ok(None),
Value::String(s) => {
if s.len() > MAX_TEXT_BYTES {
return Err(invalid(format!("`{key}` is longer than {MAX_TEXT_BYTES} bytes")));
}
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
return Err(invalid(format!("`{key}` has a control character")));
}
Ok(Some(Value::String(s)))
}
_ => Err(invalid(format!("`{key}` takes text"))),
};
}
if is_namespaced(key) {
return match value {
Value::Null => Ok(None),
Value::Object(m) => {
let v = Value::Object(m);
if depth(&v) > MAX_VALUE_DEPTH {
return Err(invalid(format!("`{key}` is nested more than {MAX_VALUE_DEPTH} levels deep")));
}
if json_len(&v) > MAX_VALUE_BYTES {
return Err(invalid(format!("`{key}` is larger than {} KiB", MAX_VALUE_BYTES / 1024)));
}
Ok(Some(v))
}
_ => Err(invalid(format!("`{key}` must hold an object (a tool keeps its own data as an object under its reverse-DNS key)"))),
};
}
let shown: String = key.chars().take(64).collect();
Err(invalid(format!(
"`{shown}` is not a design property: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
WELL_KNOWN.join(", ")
)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn well_known_keys_read_as_text_and_ignore_other_kinds() {
let m: Metadata = serde_json::from_value(json!({"author": "Jane Doe", "revision": 3, "project": "", "license": null})).unwrap();
assert_eq!(m.author(), Some("Jane Doe"));
assert_eq!(m.revision(), None, "a number is kept but is not a revision");
assert_eq!(m.project(), None, "empty text reads as absent");
assert_eq!(m.license(), None);
assert_eq!(m.get("revision"), Some(&json!(3)), "kept as it was");
}
#[test]
fn set_checks_keys_kinds_and_sizes() {
let mut m = Metadata::default();
assert!(m.set("author", json!("Jane")).unwrap());
assert!(!m.set("author", json!("Jane")).unwrap(), "the same value is no change");
assert!(m.set("com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
assert!(m.set("author", json!(42)).is_err(), "well-known keys take text");
assert!(m.set("author", json!("a\u{7}b")).is_err(), "no control characters");
assert!(m.set("description", json!("two\nlines\tand a tab")).unwrap());
assert!(m.set("com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
assert!(m.set("colour", json!("red")).is_err(), "unprefixed keys are reserved");
assert!(m.set("", json!({})).is_err());
for bad in ["com.", ".com", "com..x", "com.ex ample", "com.é", "single"] {
assert!(m.set(bad, json!({})).is_err(), "{bad}");
}
assert!(m.set("author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
assert!(m.set("org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
let mut deep = json!({});
for _ in 0..MAX_VALUE_DEPTH {
deep = json!({ "a": deep });
}
assert!(m.set("org.deep", deep).is_err());
assert_eq!(m.author(), Some("Jane"), "a refused value leaves the old one");
// Null and empty text remove.
assert!(m.set("author", json!("")).unwrap());
assert!(m.set("com.acme.tool", Value::Null).unwrap());
assert!(!m.set("com.acme.tool", Value::Null).unwrap());
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
}
#[test]
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
let mut m = Metadata::default();
for i in 0..MAX_KEYS {
m.set(&format!("org.k{i}"), json!({})).unwrap();
}
assert!(m.set("org.one_more", json!({})).is_err());
assert_eq!(m.0.len(), MAX_KEYS);
// A file written past the limits by another tool still opens and can be cut down.
let mut big = Metadata::default();
for i in 0..MAX_KEYS + 10 {
big.0.insert(format!("org.k{i}"), json!({}));
}
assert!(big.set("org.k0", Value::Null).unwrap());
assert!(big.set("author", json!("Jane")).is_err(), "still over: no new keys");
assert!(big.set("org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
}
}