Files
OneCAD-Simulation/crates/io/src/lib.rs
T

447 lines
17 KiB
Rust

//! FieldCraft's files.
//!
//! A `.fieldcraft` file ([`write`], [`read`]; docs/formats/fieldcraft.md) is a ZIP package of four
//! parts, stored uncompressed so each can be read with any unzip tool:
//!
//! - `fieldcraft.json`: the manifest. The format tag (`fieldcraft/1`), the program that wrote
//! it, the geometry the model is built on (the SolidCraft design and the imported 3MF meshes,
//! each a path and the SHA-256 of its content, opened again when the model is), the view state
//! ([`View`]: what the viewport hides), and the size and SHA-256 of every other part.
//! - `model.json`: the model definition (unit system, sets, materials, sections, reference
//! points, couplings, boundary conditions, loads, steps) as pretty-printed JSON with a stable
//! key order, so two versions of a model diff line by line.
//! - `mesh.bin`: nodes, elements and geometry tags in a compact little-endian binary form
//! ([`mesh`]).
//! - `journal.json`: the commands that built the model, one per line: a script that replays.
//!
//! The same model always gives the same bytes. Reading treats the file as hostile: every size is
//! capped before anything is allocated, every part's checksum is verified, and the model is
//! rebuilt through the same checked methods commands use, so a file cannot hold what commands
//! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`],
//! [`recovery`] and [`roots`] touch the file system, which in the browser is [`memory`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod fs;
pub mod memory;
pub mod mesh;
pub mod recovery;
pub mod roots;
pub mod stl;
pub mod vtk;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write};
use fieldcraft_model::Model;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
/// The format tag of the files this version writes and reads.
pub const FORMAT: &str = "fieldcraft/1";
/// The file name extension.
pub const EXTENSION: &str = "fieldcraft";
/// Largest file read.
pub const MAX_FILE_BYTES: u64 = 8 << 30;
/// Largest manifest, model definition or journal part.
pub const MAX_TEXT_PART_BYTES: u64 = 256 << 20;
/// Most journal entries read.
pub const MAX_JOURNAL_ENTRIES: usize = 1_000_000;
/// Most imported meshes a model refers to.
pub const MAX_MESH_SOURCES: usize = 10_000;
/// Longest geometry path, command id.
pub const MAX_PATH_LEN: usize = 4096;
const MAX_COMMAND_LEN: usize = 256;
const MANIFEST: &str = "fieldcraft.json";
const MODEL: &str = "model.json";
const MESH: &str = "mesh.bin";
const JOURNAL: &str = "journal.json";
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
/// Not a `.fieldcraft` file, or a damaged or invalid one.
#[error("{0}")]
Invalid(String),
/// A `.fieldcraft` file of a format version this program does not read.
#[error("{0}")]
Version(String),
#[error("limit reached: {0}")]
Limit(String),
/// The file system refused.
#[error("{0}")]
Io(String),
/// The path lies outside the folders file access is confined to ([`roots::Roots`]).
#[error("{0}")]
Refused(String),
}
pub type Result<T> = std::result::Result<T, Error>;
fn invalid<T>(message: impl Into<String>) -> Result<T> {
Err(Error::Invalid(message.into()))
}
/// A model error met while reading `part`.
pub(crate) fn model_error(part: &str, e: fieldcraft_model::Error) -> Error {
match e {
fieldcraft_model::Error::Limit(m) => Error::Limit(format!("{part}: {m}")),
e => Error::Invalid(format!("{part}: {e}")),
}
}
/// The lower-case hexadecimal SHA-256 of `bytes`.
pub fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// A file the model is built on: its path and the SHA-256 of the content that was opened.
///
/// A relative path is relative to the folder of the `.fieldcraft` file that refers to it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "SourceFileRepr", into = "SourceFileRepr")]
pub struct SourceFile {
path: String,
sha256: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SourceFileRepr {
path: String,
sha256: String,
}
impl TryFrom<SourceFileRepr> for SourceFile {
type Error = Error;
fn try_from(r: SourceFileRepr) -> Result<SourceFile> {
SourceFile::new(r.path, r.sha256)
}
}
impl From<SourceFile> for SourceFileRepr {
fn from(s: SourceFile) -> Self {
SourceFileRepr { path: s.path, sha256: s.sha256 }
}
}
impl SourceFile {
/// A path (1 to [`MAX_PATH_LEN`] bytes, no control characters) and a lower-case hexadecimal
/// SHA-256.
pub fn new(path: impl Into<String>, sha256: impl Into<String>) -> Result<SourceFile> {
let (path, sha256) = (path.into(), sha256.into());
if path.is_empty() || path.len() > MAX_PATH_LEN || path.chars().any(char::is_control) {
return invalid(format!("a geometry path must be 1 to {MAX_PATH_LEN} bytes with no control characters"));
}
if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
return invalid(format!("`{}` is not a SHA-256 (64 lower-case hexadecimal digits)", sha256.chars().take(80).collect::<String>()));
}
Ok(SourceFile { path, sha256 })
}
/// The file `path`, with the hash of `bytes` (what was read from it).
pub fn of(path: impl Into<String>, bytes: &[u8]) -> Result<SourceFile> {
SourceFile::new(path, sha256_hex(bytes))
}
pub fn path(&self) -> &str {
&self.path
}
pub fn sha256(&self) -> &str {
&self.sha256
}
/// The same content at another path.
pub fn with_path(&self, path: impl Into<String>) -> Result<SourceFile> {
SourceFile::new(path, self.sha256.clone())
}
}
/// The geometry a model is built on.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Geometry {
/// The SolidCraft design.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub design: Option<SourceFile>,
/// Face-tagged 3MF meshes, in the order they were imported (their surfaces follow each other
/// in that order).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub meshes: Vec<SourceFile>,
}
/// Most items a view hides.
pub const MAX_HIDDEN: usize = 100_000;
/// Longest name of a hidden item (a body's name is a geometry name).
const MAX_HIDDEN_NAME: usize = fieldcraft_model::geometry::MAX_GEOMETRY_NAME_LEN;
/// The kinds of items the viewport draws one by one, and so can hide.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
/// A body of the open design (or a cell of a partitioned one).
Body,
/// An imported surface.
Surface,
Datum,
BoundaryCondition,
Load,
ReferencePoint,
Coupling,
}
impl ItemKind {
pub const ALL: [ItemKind; 7] = [
ItemKind::Body,
ItemKind::Surface,
ItemKind::Datum,
ItemKind::BoundaryCondition,
ItemKind::Load,
ItemKind::ReferencePoint,
ItemKind::Coupling,
];
}
/// An item of the viewport, by kind and name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Item {
pub kind: ItemKind,
pub name: String,
}
/// View state saved with a model (not part of its definition): the items the viewport hides.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct View {
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub hidden: BTreeSet<Item>,
}
impl View {
pub fn is_empty(&self) -> bool {
self.hidden.is_empty()
}
pub fn is_hidden(&self, kind: ItemKind, name: &str) -> bool {
self.hidden.contains(&Item { kind, name: name.to_string() })
}
fn check(&self) -> Result<()> {
if self.hidden.len() > MAX_HIDDEN {
return Err(Error::Limit(format!("a view hides at most {MAX_HIDDEN} items")));
}
if self.hidden.iter().any(|i| i.name.is_empty() || i.name.len() > MAX_HIDDEN_NAME) {
return invalid(format!("{MANIFEST}: a hidden item's name is 1 to {MAX_HIDDEN_NAME} bytes"));
}
Ok(())
}
}
/// One journal entry: a command id and its parameters.
pub type JournalEntry = (String, Value);
/// What a `.fieldcraft` file holds.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModelFile {
pub model: Model,
pub geometry: Geometry,
pub journal: Vec<JournalEntry>,
pub view: View,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
format: String,
generator: String,
#[serde(default)]
geometry: Geometry,
#[serde(default, skip_serializing_if = "View::is_empty")]
view: View,
parts: BTreeMap<String, Part>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Part {
bytes: u64,
sha256: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct JournalLine {
command: String,
#[serde(default)]
params: Value,
}
/// A journal entry as written (borrowed, so saving copies no parameters).
#[derive(Serialize)]
struct JournalLineRef<'a> {
command: &'a str,
params: &'a Value,
}
fn json_error(part: &str, e: serde_json::Error) -> Error {
Error::Invalid(format!("{part}: {e}"))
}
/// The journal as text: one entry per line, so a diff shows the commands added.
fn journal_text(journal: &[JournalEntry]) -> Result<Vec<u8>> {
let mut out = b"[".to_vec();
for (i, (command, params)) in journal.iter().enumerate() {
out.extend_from_slice(if i == 0 { b"\n " } else { b",\n " });
serde_json::to_writer(&mut out, &JournalLineRef { command, params }).map_err(|e| json_error(JOURNAL, e))?;
}
out.extend_from_slice(if journal.is_empty() { b"]\n" } else { b"\n]\n" });
Ok(out)
}
/// The bytes of a `.fieldcraft` file holding `model`, built on `geometry` by the commands in
/// `journal`, shown with `view`. The same arguments always give the same bytes.
pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry], view: &View) -> Result<Vec<u8>> {
if geometry.meshes.len() > MAX_MESH_SOURCES {
return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes")));
}
view.check()?;
let mut model_json = serde_json::to_vec_pretty(&model.without_mesh()).map_err(|e| json_error(MODEL, e))?;
model_json.push(b'\n');
let mesh = mesh::encode(model)?;
let journal = journal_text(journal)?;
let parts = [(MODEL, model_json), (MESH, mesh), (JOURNAL, journal)];
let manifest = Manifest {
format: FORMAT.into(),
generator: format!("FieldCraft {}", env!("CARGO_PKG_VERSION")),
geometry: geometry.clone(),
view: view.clone(),
parts: parts.iter().map(|(name, bytes)| (name.to_string(), Part { bytes: bytes.len() as u64, sha256: sha256_hex(bytes) })).collect(),
};
let mut manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|e| json_error(MANIFEST, e))?;
manifest_json.push(b'\n');
let total: usize = parts.iter().map(|(_, b)| b.len()).sum::<usize>() + manifest_json.len();
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::with_capacity(total.saturating_add(1024))));
let zip_error = |e: zip::result::ZipError| Error::Io(format!("cannot build the package: {e}"));
for (name, bytes) in std::iter::once((MANIFEST, &manifest_json)).chain(parts.iter().map(|(n, b)| (*n, b))) {
// Fixed time and permissions: the bytes depend on the model only.
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored)
.last_modified_time(zip::DateTime::default())
.unix_permissions(0o644)
.large_file(bytes.len() as u64 >= u64::from(u32::MAX));
zip.start_file(name, options).map_err(zip_error)?;
zip.write_all(bytes).map_err(|e| Error::Io(format!("cannot build the package: {e}")))?;
}
Ok(zip.finish().map_err(zip_error)?.into_inner())
}
/// Read a part of at most `cap` bytes, checking its size and ZIP checksum.
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, cap: u64, file_len: usize) -> Result<Vec<u8>> {
let mut f = zip.by_name(name).map_err(|e| Error::Invalid(format!("no `{name}` part: {e}")))?;
let size = f.size();
if size > cap {
return Err(Error::Limit(format!("part `{name}` is {size} bytes (at most {cap})")));
}
// Declared sizes are not trusted for allocation beyond the input itself.
let mut buf = Vec::with_capacity(usize::try_from(size).unwrap_or(usize::MAX).min(file_len));
(&mut f).take(cap.saturating_add(1)).read_to_end(&mut buf).map_err(|e| Error::Invalid(format!("part `{name}` is damaged: {e}")))?;
if buf.len() as u64 != size {
return invalid(format!("part `{name}` is damaged: {} bytes where {size} were declared", buf.len()));
}
Ok(buf)
}
/// Read a `.fieldcraft` file.
pub fn read(bytes: &[u8]) -> Result<ModelFile> {
if bytes.len() as u64 > MAX_FILE_BYTES {
return Err(Error::Limit(format!("the file is {} MB (at most {} MB)", bytes.len() >> 20, MAX_FILE_BYTES >> 20)));
}
if !bytes.starts_with(b"PK\x03\x04") {
return invalid("not a FieldCraft file (not a ZIP package)");
}
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| Error::Invalid(format!("not a FieldCraft file: {e}")))?;
let names = [MANIFEST, MODEL, MESH, JOURNAL];
let mut seen = Vec::with_capacity(zip.len());
for name in zip.file_names() {
let name = name.map_err(|e| Error::Invalid(format!("not a FieldCraft file: {e}")))?;
if !names.contains(&name.as_ref()) {
return invalid(format!("unexpected part `{}`", name.chars().take(80).collect::<String>()));
}
if seen.contains(&name) {
return invalid(format!("part `{name}` appears twice"));
}
seen.push(name);
}
let manifest_bytes = read_part(&mut zip, MANIFEST, MAX_TEXT_PART_BYTES, bytes.len())?;
let manifest: Value = serde_json::from_slice(&manifest_bytes).map_err(|e| json_error(MANIFEST, e))?;
match manifest.get("format").and_then(Value::as_str) {
Some(FORMAT) => {}
Some(f) if f.starts_with("fieldcraft/") => {
return Err(Error::Version(format!(
"the file is format `{}`; this version of FieldCraft reads `{FORMAT}`",
f.chars().take(40).collect::<String>()
)));
}
_ => return invalid(format!("not a FieldCraft file: `{MANIFEST}` has no `{FORMAT}` format tag")),
}
let manifest: Manifest = serde_json::from_value(manifest).map_err(|e| json_error(MANIFEST, e))?;
if manifest.geometry.meshes.len() > MAX_MESH_SOURCES {
return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes")));
}
manifest.view.check()?;
if manifest.parts.len() != 3 {
return invalid(format!("`{MANIFEST}` must list exactly the parts {MODEL}, {MESH} and {JOURNAL}"));
}
let mut part = |name: &str, cap: u64| -> Result<Vec<u8>> {
let listed = manifest.parts.get(name).ok_or_else(|| Error::Invalid(format!("`{MANIFEST}` does not list `{name}`")))?;
let data = read_part(&mut zip, name, cap, bytes.len())?;
if data.len() as u64 != listed.bytes || sha256_hex(&data) != listed.sha256 {
return invalid(format!("part `{name}` is damaged: its size or SHA-256 differs from the manifest"));
}
Ok(data)
};
let model_json = part(MODEL, MAX_TEXT_PART_BYTES)?;
let mesh_bin = part(MESH, mesh::MAX_BYTES)?;
let journal_json = part(JOURNAL, MAX_TEXT_PART_BYTES)?;
let mut model: Model = serde_json::from_slice(&model_json).map_err(|e| json_error(MODEL, e))?;
if !model.nodes().is_empty() || !model.elements().is_empty() || !model.classification().entities().is_empty() {
return invalid(format!("`{MODEL}` holds mesh data; the mesh belongs in `{MESH}`"));
}
drop(model_json);
mesh::decode_into(&mut model, &mesh_bin)?;
drop(mesh_bin);
let journal = read_journal(&journal_json)?;
Ok(ModelFile { model, geometry: manifest.geometry, journal, view: manifest.view })
}
fn read_journal(bytes: &[u8]) -> Result<Vec<JournalEntry>> {
let lines: Vec<JournalLine> = serde_json::from_slice(bytes).map_err(|e| json_error(JOURNAL, e))?;
if lines.len() > MAX_JOURNAL_ENTRIES {
return Err(Error::Limit(format!("a journal holds at most {MAX_JOURNAL_ENTRIES} commands")));
}
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
if line.command.is_empty() || line.command.len() > MAX_COMMAND_LEN || line.command.chars().any(char::is_control) {
return invalid(format!("{JOURNAL}: entry {}: not a command id", i + 1));
}
Ok((line.command, line.params))
})
.collect()
}