The .modelcraft file, undo/redo on the journal, atomic saves and autosave recovery (M3) (#21)

* modelcraft-io: the .modelcraft file (modelcraft/1), atomic writes, autosave storage

A stored ZIP of a manifest (format tag, geometry sources with SHA-256,
part sizes and hashes), the model definition as stable pretty JSON, the
mesh and geometry tags in a compact binary part, and the journal one
command per line. Reading caps every size, checks every checksum and
replays the model through its checked methods. Model::without_mesh and
empty mesh fields left out of the model's JSON; geometry::read_file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: undo/redo on the journal, file.open/save/save_as, autosave and recovery

CommandSpec gains a kind: Model commands are journalled and become undo
steps; Session commands (file.*, edit.*) manage the session. Undo history
is bounded by steps and estimated memory. Geometry sources are recorded
with content hashes and reopened on file.open (missing or changed files
are warnings). Desktop app autosaves every 30 s and reports recoverable
models; Ctrl/Cmd+Z and Shift+Z/Y undo and redo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: the .modelcraft format, undo history, roadmap and parity status

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* simplify: shared path parameter, one load path for open/recover, history accounting in History

Session commands skip the per-command model copy; saved revision is an
Option; journal written from borrowed entries and read without an
intermediate tree; backups copied by the file system; recovery lock and
error helpers; geometry file wrappers use read_file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:22:01 -04:00
1 parent 9fb00317ad
commit 2d99a8571c
32 files changed
+2606 -54

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
//! The file system edge: crash-safe writes and capped reads.
//!
//! A save writes the new content to a temporary file next to the target, flushes it to disk and
//! replaces the target with one rename, then flushes the directory. A crash or power cut at any
//! moment leaves the old file or the new one, never a torn mix. The file being replaced can be
//! kept as `<file>.bak` (itself written the same way).
//!
//! Adapted from SolidCraft's `crates/io/src/safe_write.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md.
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use crate::{Error, Result};
fn io_error(path: &Path, e: std::io::Error) -> Error {
Error::Io(format!("{}: {e}", path.display()))
}
/// The backup of a file written with `backup: true`.
pub fn backup_path(path: &Path) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".bak");
PathBuf::from(s)
}
fn temp_beside(path: &Path, tag: &str) -> PathBuf {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
path.with_file_name(format!(".{name}.{tag}.{}.{seq}.tmp", std::process::id()))
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = OpenOptions::new().write(true).create_new(true).open(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Flush the directory entry (the rename) to disk where the platform allows it.
fn sync_dir(path: &Path) {
#[cfg(unix)]
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty())
&& let Ok(d) = fs::File::open(dir)
{
let _ = d.sync_all();
}
#[cfg(not(unix))]
let _ = path;
}
/// Steps of a save, so tests can stop one part way (as a crash would).
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum Step {
TempWritten,
BackupWritten,
Replaced,
}
fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Step) -> std::io::Result<()> {
let tmp = temp_beside(path, "new");
if let Err(e) = write_synced(&tmp, bytes) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
if stop == Step::TempWritten {
return Ok(());
}
if backup && path.is_file() {
// Copy (not move) the old file, so the target exists at every moment.
let bak_tmp = temp_beside(path, "bak");
let r = fs::copy(path, &bak_tmp)
.and_then(|_| OpenOptions::new().write(true).open(&bak_tmp))
.and_then(|f| f.sync_all())
.and_then(|_| fs::rename(&bak_tmp, backup_path(path)));
if let Err(e) = r {
let _ = fs::remove_file(&bak_tmp);
let _ = fs::remove_file(&tmp);
return Err(e);
}
}
if stop == Step::BackupWritten {
return Ok(());
}
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
sync_dir(path);
// Temporary files of saves that crashed part way (a minute old or more) go.
for t in stale_temps(path) {
if fs::metadata(&t).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).is_some_and(|age| age.as_secs() >= 60) {
let _ = fs::remove_file(t);
}
}
Ok(())
}
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
/// `<path>.bak` first.
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> Result<()> {
write_steps(path, bytes, backup, Step::Replaced).map_err(|e| io_error(path, e))
}
/// Leftover temporary files of saves that never finished (a crash part way), next to `path`.
pub fn stale_temps(path: &Path) -> Vec<PathBuf> {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return Vec::new() };
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
let prefix = format!(".{name}.");
fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.file_name().map(|n| n.to_string_lossy().to_string()).is_some_and(|n| n.starts_with(&prefix) && n.ends_with(".tmp")))
.take(10_000)
.collect()
}
/// Read a regular file of at most `max` bytes.
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>> {
let meta = fs::metadata(path).map_err(|e| io_error(path, e))?;
if !meta.is_file() {
return Err(Error::Io(format!("{}: not a regular file", path.display())));
}
if meta.len() > max {
return Err(Error::Limit(format!("{}: {} MB (at most {} MB)", path.display(), meta.len() >> 20, max >> 20)));
}
let f = fs::File::open(path).map_err(|e| io_error(path, e))?;
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
// The file may grow between the size check and the read.
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| io_error(path, e))?;
if bytes.len() as u64 > max {
return Err(Error::Limit(format!("{}: larger than {} MB", path.display(), max >> 20)));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("modelcraft-fs-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn replaces_and_keeps_a_backup() {
let d = dir("bak");
let p = d.join("a.modelcraft");
write_atomic(&p, b"one", true).unwrap();
assert!(!backup_path(&p).exists(), "nothing to back up the first time");
write_atomic(&p, b"two", true).unwrap();
assert_eq!(fs::read(&p).unwrap(), b"two");
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
write_atomic(&p, b"three", false).unwrap();
assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one");
assert!(stale_temps(&p).is_empty());
let _ = fs::remove_dir_all(&d);
}
/// A save stopped after any step leaves the old file whole (or the new one).
#[test]
fn a_save_stopped_part_way_keeps_the_old_file() {
let d = dir("stop");
let p = d.join("a.modelcraft");
write_atomic(&p, b"old", true).unwrap();
for stop in [Step::TempWritten, Step::BackupWritten, Step::Replaced] {
write_steps(&p, b"new content", true, stop).unwrap();
let now = fs::read(&p).unwrap();
if stop < Step::Replaced {
assert_eq!(now, b"old", "{stop:?}");
assert!(!stale_temps(&p).is_empty(), "the unfinished save's temporary file is left over");
for t in stale_temps(&p) {
fs::remove_file(t).unwrap();
}
} else {
assert_eq!(now, b"new content");
}
write_atomic(&p, b"old", false).unwrap();
}
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_failed_write_leaves_the_target() {
let d = dir("fail");
let p = d.join("missing-dir").join("a.modelcraft");
assert!(write_atomic(&p, b"x", true).is_err());
assert!(!p.exists());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn reads_are_capped() {
let d = dir("read");
let p = d.join("a.bin");
fs::write(&p, [7u8; 100]).unwrap();
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
assert!(matches!(read_capped(&p, 99), Err(Error::Limit(_))));
assert!(read_capped(&d, 1 << 20).is_err());
assert!(read_capped(&d.join("missing"), 1 << 20).is_err());
let _ = fs::remove_dir_all(&d);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! ModelCraft's files.
//!
//! A `.modelcraft` file ([`write`], [`read`]; docs/formats/modelcraft.md) is a ZIP package of four
//! parts, stored uncompressed so each can be read with any unzip tool:
//!
//! - `modelcraft.json`: the manifest. The format tag (`modelcraft/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), 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`] and
//! [`recovery`] touch the file system.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod fs;
pub mod mesh;
pub mod recovery;
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
use modelcraft_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 = "modelcraft/1";
/// The file name extension.
pub const EXTENSION: &str = "modelcraft";
/// 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 = "modelcraft.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 `.modelcraft` file, or a damaged or invalid one.
#[error("{0}")]
Invalid(String),
/// A `.modelcraft` 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),
}
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: modelcraft_model::Error) -> Error {
match e {
modelcraft_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 `.modelcraft` 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>,
}
/// One journal entry: a command id and its parameters.
pub type JournalEntry = (String, Value);
/// What a `.modelcraft` file holds.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ModelFile {
pub model: Model,
pub geometry: Geometry,
pub journal: Vec<JournalEntry>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
format: String,
generator: String,
#[serde(default)]
geometry: Geometry,
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 `.modelcraft` file holding `model`, built on `geometry` by the commands in
/// `journal`. The same arguments always give the same bytes.
pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> 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")));
}
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!("ModelCraft {}", env!("CARGO_PKG_VERSION")),
geometry: geometry.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 `.modelcraft` 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 ModelCraft file (not a ZIP package)");
}
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| Error::Invalid(format!("not a ModelCraft file: {e}")))?;
let names = [MANIFEST, MODEL, MESH, JOURNAL];
let mut seen = Vec::with_capacity(zip.len());
for name in zip.file_names() {
if !names.contains(&name) {
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("modelcraft/") => {
return Err(Error::Version(format!(
"the file is format `{}`; this version of ModelCraft reads `{FORMAT}`",
f.chars().take(40).collect::<String>()
)));
}
_ => return invalid(format!("not a ModelCraft 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")));
}
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 })
}
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()
}
+306
View File
@@ -0,0 +1,306 @@
//! The binary mesh part of a `.modelcraft` file (`mesh.bin`): nodes, elements and the geometry
//! tags that say how the mesh lies on the geometry.
//!
//! Little-endian throughout:
//!
//! ```text
//! "MCMESH01" magic, 8 bytes
//! u64 nodes, u64 elements, u64 entities, u64 tagged elements, u64 tagged nodes
//! nodes × { u32 label, f64 x, f64 y, f64 z } labels ascending
//! elements × { u32 label, u8 kind, u32 node × n(kind) } labels ascending
//! entities × { u8 kind (0 body, 1 face, 2 edge, 3 vertex),
//! u16 length, body name (UTF-8), [u16 length, entity name] unless a body }
//! tagged elements × { u32 element, u32 region (0xFFFF_FFFF: none),
//! u8 count, count × { u8 face, u32 tag },
//! u8 count, count × { u8 edge, u32 tag } } elements ascending
//! tagged nodes × { u32 node, u32 tag } nodes ascending
//! ```
//!
//! Element kinds are numbered by [`KIND_CODES`] (fixed by the format, never by the enum's
//! order). A tag is an index into the entity list. The part's integrity is checked by its SHA-256
//! in the manifest; decoding checks every count against the bytes present before reading, and
//! every value through the model's own checked methods.
use modelcraft_model::geometry::MAX_ENTITIES;
use modelcraft_model::{ElementKind, Entity, GeometryRef, MAX_ELEMENTS, MAX_NODES, Model};
use crate::{Error, MAX_FILE_BYTES, Result, invalid, model_error};
/// The magic bytes the part starts with (the `01` is the mesh encoding's version).
pub const MAGIC: &[u8; 8] = b"MCMESH01";
/// Largest mesh part read.
pub const MAX_BYTES: u64 = MAX_FILE_BYTES;
const PART: &str = "mesh.bin";
/// No region tag.
const NO_TAG: u32 = u32::MAX;
/// The element kind codes of the format.
pub const KIND_CODES: [(ElementKind, u8); 11] = [
(ElementKind::Rod2, 1),
(ElementKind::Beam2, 2),
(ElementKind::Tri3, 3),
(ElementKind::Tri6, 4),
(ElementKind::Quad4, 5),
(ElementKind::Quad8, 6),
(ElementKind::Tet4, 7),
(ElementKind::Tet10, 8),
(ElementKind::Wedge6, 9),
(ElementKind::Hex8, 10),
(ElementKind::Hex20, 11),
];
fn kind_code(kind: ElementKind) -> u8 {
KIND_CODES.iter().find(|(k, _)| *k == kind).map_or(0, |(_, c)| *c)
}
fn kind_of(code: u8) -> Option<ElementKind> {
KIND_CODES.iter().find(|(_, c)| *c == code).map(|(k, _)| *k)
}
/// Bytes of the fixed header.
const HEADER: usize = 8 + 5 * 8;
/// Smallest record of each table (for the count checks before reading).
const NODE_BYTES: u64 = 4 + 3 * 8;
const MIN_ELEMENT_BYTES: u64 = 4 + 1 + 2 * 4;
const MIN_ENTITY_BYTES: u64 = 1 + 2 + 1;
const MIN_ELEMENT_TAG_BYTES: u64 = 4 + 4 + 1 + 1;
const NODE_TAG_BYTES: u64 = 4 + 4;
fn put_u32(out: &mut Vec<u8>, v: u32) {
out.extend_from_slice(&v.to_le_bytes());
}
fn put_u64(out: &mut Vec<u8>, v: usize) {
out.extend_from_slice(&(v as u64).to_le_bytes());
}
fn put_name(out: &mut Vec<u8>, name: &str) -> Result<()> {
let len =
u16::try_from(name.len()).map_err(|_| Error::Limit(format!("geometry name `{}…` is too long", name.chars().take(40).collect::<String>())))?;
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(name.as_bytes());
Ok(())
}
/// Encode the model's mesh and geometry tags.
pub fn encode(model: &Model) -> Result<Vec<u8>> {
let c = model.classification();
let element_bytes: usize = model.elements().values().map(|e| 5 + 4 * e.nodes.len()).sum();
let mut out = Vec::with_capacity(HEADER + model.nodes().len() * NODE_BYTES as usize + element_bytes + c.nodes().len() * NODE_TAG_BYTES as usize);
out.extend_from_slice(MAGIC);
for n in [model.nodes().len(), model.elements().len(), c.entities().len(), c.elements().len(), c.nodes().len()] {
put_u64(&mut out, n);
}
for (&label, node) in model.nodes() {
put_u32(&mut out, label);
for x in node.xyz {
out.extend_from_slice(&x.to_le_bytes());
}
}
for (&label, e) in model.elements() {
put_u32(&mut out, label);
out.push(kind_code(e.kind));
for &n in &e.nodes {
put_u32(&mut out, n);
}
}
for g in c.entities() {
out.push(match g.entity {
Entity::Body => 0,
Entity::Face(_) => 1,
Entity::Edge(_) => 2,
Entity::Vertex(_) => 3,
});
put_name(&mut out, &g.body)?;
if let Some(name) = g.entity.name() {
put_name(&mut out, name)?;
}
}
for (&element, tags) in c.elements() {
put_u32(&mut out, element);
put_u32(&mut out, tags.region.unwrap_or(NO_TAG));
for map in [&tags.faces, &tags.edges] {
// An element has at most 12 edges and 6 faces; the model checks the numbers.
out.push(u8::try_from(map.len()).map_err(|_| Error::Limit(format!("element {element} has too many tags")))?);
for (&number, &tag) in map {
out.push(number);
put_u32(&mut out, tag);
}
}
}
for (&node, &tag) in c.nodes() {
put_u32(&mut out, node);
put_u32(&mut out, tag);
}
Ok(out)
}
/// A cursor over the part's bytes; every read fails cleanly at the end.
struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
let end = self.pos.checked_add(n).filter(|&e| e <= self.bytes.len());
match end.and_then(|e| self.bytes.get(self.pos..e)) {
Some(s) => {
self.pos += n;
Ok(s)
}
None => invalid(format!("{PART} ends early (at byte {})", self.pos)),
}
}
fn array<const N: usize>(&mut self) -> Result<[u8; N]> {
let mut a = [0u8; N];
a.copy_from_slice(self.take(N)?);
Ok(a)
}
fn u8(&mut self) -> Result<u8> {
Ok(self.array::<1>()?[0])
}
fn u16(&mut self) -> Result<u16> {
Ok(u16::from_le_bytes(self.array()?))
}
fn u32(&mut self) -> Result<u32> {
Ok(u32::from_le_bytes(self.array()?))
}
fn u64(&mut self) -> Result<u64> {
Ok(u64::from_le_bytes(self.array()?))
}
fn f64(&mut self) -> Result<f64> {
Ok(f64::from_le_bytes(self.array()?))
}
fn name(&mut self, what: &str) -> Result<String> {
let len = usize::from(self.u16()?);
String::from_utf8(self.take(len)?.to_vec()).map_err(|_| Error::Invalid(format!("{PART}: a {what} name is not UTF-8")))
}
fn remaining(&self) -> u64 {
self.bytes.len().saturating_sub(self.pos) as u64
}
}
/// A count from the header, checked against its cap.
fn count(r: &mut Reader, what: &str, cap: usize) -> Result<usize> {
let n = r.u64()?;
match usize::try_from(n) {
Ok(n) if n <= cap => Ok(n),
_ => Err(Error::Limit(format!("{PART}: {n} {what} (at most {cap})"))),
}
}
/// Labels must strictly increase: no duplicates, and one byte order for one mesh.
fn ascending(what: &str, previous: &mut Option<u32>, label: u32) -> Result<()> {
if previous.is_some_and(|p| label <= p) {
return invalid(format!("{PART}: {what} {label} is out of order or repeated"));
}
*previous = Some(label);
Ok(())
}
/// Decode a mesh part into `model`, which must have no mesh yet.
pub fn decode_into(model: &mut Model, bytes: &[u8]) -> Result<()> {
if !model.nodes().is_empty() || !model.elements().is_empty() {
return invalid("the model already has a mesh");
}
if bytes.len() as u64 > MAX_BYTES {
return Err(Error::Limit(format!("{PART} is larger than {} MB", MAX_BYTES >> 20)));
}
let mut r = Reader { bytes, pos: 0 };
if r.take(MAGIC.len()).ok() != Some(MAGIC.as_slice()) {
return invalid(format!("{PART} is not a ModelCraft mesh (no {} header)", String::from_utf8_lossy(MAGIC)));
}
let nodes = count(&mut r, "nodes", MAX_NODES)?;
let elements = count(&mut r, "elements", MAX_ELEMENTS)?;
let entities = count(&mut r, "geometry entities", MAX_ENTITIES)?;
let tagged_elements = count(&mut r, "tagged elements", elements)?;
let tagged_nodes = count(&mut r, "tagged nodes", nodes)?;
// Every table's smallest size must fit in what is left, before anything is read.
let least = [
(nodes, NODE_BYTES),
(elements, MIN_ELEMENT_BYTES),
(entities, MIN_ENTITY_BYTES),
(tagged_elements, MIN_ELEMENT_TAG_BYTES),
(tagged_nodes, NODE_TAG_BYTES),
]
.iter()
.try_fold(0u64, |sum, &(n, size)| (n as u64).checked_mul(size).and_then(|b| sum.checked_add(b)));
if least.is_none_or(|b| b > r.remaining()) {
return invalid(format!("{PART} is too short for the counts in its header"));
}
let mut last = None;
for _ in 0..nodes {
let label = r.u32()?;
ascending("node", &mut last, label)?;
let xyz = [r.f64()?, r.f64()?, r.f64()?];
model.set_node(label, xyz).map_err(|e| model_error(PART, e))?;
}
let mut last = None;
for _ in 0..elements {
let label = r.u32()?;
ascending("element", &mut last, label)?;
let code = r.u8()?;
let kind = kind_of(code).ok_or_else(|| Error::Invalid(format!("{PART}: element {label} has unknown kind {code}")))?;
let connectivity = (0..kind.node_count()).map(|_| r.u32()).collect::<Result<Vec<u32>>>()?;
model.set_element(label, kind, connectivity).map_err(|e| model_error(PART, e))?;
}
for i in 0..entities {
let kind = r.u8()?;
let body = r.name("body")?;
let entity = match kind {
0 => Entity::Body,
1 => Entity::Face(r.name("face")?),
2 => Entity::Edge(r.name("edge")?),
3 => Entity::Vertex(r.name("vertex")?),
k => return invalid(format!("{PART}: geometry entity {i} has unknown kind {k}")),
};
let g = GeometryRef::new(body, entity).map_err(|e| model_error(PART, e))?;
if model.geometry_tag(&g).map_err(|e| model_error(PART, e))? as usize != i {
return invalid(format!("{PART}: {g} is listed twice among the geometry entities"));
}
}
let mut last = None;
for _ in 0..tagged_elements {
let element = r.u32()?;
ascending("tagged element", &mut last, element)?;
let region = r.u32()?;
if region != NO_TAG {
model.tag_element(element, region).map_err(|e| model_error(PART, e))?;
}
for face in [true, false] {
let mut previous = None;
for _ in 0..r.u8()? {
let (number, tag) = (r.u8()?, r.u32()?);
if previous.is_some_and(|p| number <= p) {
return invalid(format!("{PART}: element {element}'s tags are out of order or repeated"));
}
previous = Some(number);
let tagged = if face { model.tag_element_face(element, number, tag) } else { model.tag_element_edge(element, number, tag) };
tagged.map_err(|e| model_error(PART, e))?;
}
}
}
let mut last = None;
for _ in 0..tagged_nodes {
let node = r.u32()?;
ascending("tagged node", &mut last, node)?;
model.tag_node(node, r.u32()?).map_err(|e| model_error(PART, e))?;
}
if r.remaining() != 0 {
return invalid(format!("{PART} has {} bytes after its last table", r.remaining()));
}
Ok(())
}
+276
View File
@@ -0,0 +1,276 @@
//! Autosave storage and crash recovery.
//!
//! A running app keeps one entry in the recovery folder: `<id>.modelcraft` (the model as of the
//! last autosave), `<id>.json` (its [`Entry`]: the model's own file, when it has one, and when it
//! was autosaved) and `<id>.lock`, which the app holds locked while it runs. An entry whose lock
//! nobody holds belongs to an app that crashed or closed with unsaved changes: [`orphans`] lists
//! it for the next launch to offer.
//!
//! Adapted from SolidCraft's `crates/engine/src/recovery.rs` (MIT OR Apache-2.0), see
//! ATTRIBUTION.md. The engine decides when to autosave; this module only stores.
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::fs::{read_capped, write_atomic};
use crate::{EXTENSION, Error, MAX_FILE_BYTES, Result};
/// The recovery folder: `MODELCRAFT_RECOVERY_DIR`, else `ModelCraft/recovery` (`modelcraft/` on
/// Linux) in the platform's per-user data folder.
pub fn default_dir() -> Option<PathBuf> {
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
if let Some(d) = env("MODELCRAFT_RECOVERY_DIR") {
return Some(d);
}
let (root, name) = if cfg!(windows) {
(env("LOCALAPPDATA")?, "ModelCraft")
} else if cfg!(target_os = "macos") {
(env("HOME")?.join("Library/Application Support"), "ModelCraft")
} else {
(env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?, "modelcraft")
};
Some(root.join(name).join("recovery"))
}
/// What an entry's `<id>.json` holds.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub id: String,
/// The model's own file, if it had one.
pub path: Option<String>,
/// When it was autosaved (seconds since 1970).
pub saved_at: u64,
pub nodes: usize,
pub elements: usize,
}
/// Seconds since 1970 (0 where the platform has no clock, such as the browser's plain wasm).
fn unix_now() -> u64 {
if cfg!(target_arch = "wasm32") {
return 0;
}
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn file(dir: &Path, id: &str, ext: &str) -> PathBuf {
dir.join(format!("{id}.{ext}"))
}
/// Whether a running app holds entry `id`'s lock.
fn held(dir: &Path, id: &str) -> bool {
File::open(file(dir, id, "lock")).is_ok_and(|l| l.try_lock().is_err())
}
fn missing(id: &str) -> Error {
Error::Io(format!("no recovered model `{}`", id.chars().take(64).collect::<String>()))
}
/// Largest entry description read.
const MAX_ENTRY_BYTES: u64 = 64 << 10;
/// Most entries listed.
const MAX_LISTED: usize = 1000;
fn read_entry(path: &Path) -> Option<Entry> {
serde_json::from_slice(&read_capped(path, MAX_ENTRY_BYTES).ok()?).ok()
}
/// Entries left by apps that are no longer running, newest first.
pub fn orphans(dir: &Path) -> Vec<Entry> {
let mut out: Vec<Entry> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("json") {
return None;
}
let entry = read_entry(&p)?;
if !valid_id(&entry.id) || p.file_stem().and_then(|s| s.to_str()) != Some(entry.id.as_str()) || !file(dir, &entry.id, EXTENSION).is_file()
{
return None;
}
// Held by a running app: not an orphan.
(!held(dir, &entry.id)).then_some(entry)
})
.take(MAX_LISTED)
.collect();
out.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then_with(|| a.id.cmp(&b.id)));
out
}
/// An entry's autosaved file bytes and its description.
pub fn load(dir: &Path, id: &str) -> Result<(Vec<u8>, Entry)> {
if !valid_id(id) {
return Err(missing(id));
}
let entry = read_entry(&file(dir, id, "json")).filter(|e| e.id == id).ok_or_else(|| missing(id))?;
let bytes = read_capped(&file(dir, id, EXTENSION), MAX_FILE_BYTES)?;
Ok((bytes, entry))
}
/// Delete an entry (unless a running app holds it).
pub fn remove(dir: &Path, id: &str) -> Result<()> {
if !valid_id(id) {
return Err(missing(id));
}
if held(dir, id) {
return Err(Error::Io(format!("recovered model `{id}` belongs to a running app")));
}
for ext in ["json", EXTENSION, "lock"] {
let _ = fs::remove_file(file(dir, id, ext));
}
Ok(())
}
/// One app's entry in the recovery folder, locked while the app runs.
#[derive(Debug)]
pub struct Slot {
dir: PathBuf,
id: String,
lock: Option<File>,
has_entry: bool,
}
impl Slot {
/// Take a new entry in `dir` (created if needed), holding its lock.
pub fn new(dir: &Path) -> Result<Slot> {
let io = |e: std::io::Error| Error::Io(format!("{}: {e}", dir.display()));
fs::create_dir_all(dir).map_err(io)?;
// Locks of apps that ended with nothing to recover.
for e in fs::read_dir(dir).into_iter().flatten().flatten().take(10 * MAX_LISTED) {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("lock")
&& !p.with_extension("json").exists()
&& File::open(&p).is_ok_and(|l| l.try_lock().is_ok())
{
let _ = fs::remove_file(&p);
}
}
// Unique within the process too: an app started again in the same second (tests that
// "crash" and restart) must not take over the crashed app's entry.
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
lock.lock().map_err(io)?;
Ok(Slot { dir: dir.to_path_buf(), id, lock: Some(lock), has_entry: false })
}
pub fn id(&self) -> &str {
&self.id
}
/// Whether the slot holds an autosave.
pub fn has_entry(&self) -> bool {
self.has_entry
}
/// Store an autosave: the file's bytes, then its description (so a listed entry always has
/// its file).
pub fn write(&mut self, bytes: &[u8], path: Option<&str>, nodes: usize, elements: usize) -> Result<()> {
write_atomic(&file(&self.dir, &self.id, EXTENSION), bytes, false)?;
let entry = Entry { id: self.id.clone(), path: path.map(str::to_string), saved_at: unix_now(), nodes, elements };
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| Error::Io(e.to_string()))?;
write_atomic(&file(&self.dir, &self.id, "json"), &meta, false)?;
self.has_entry = true;
Ok(())
}
/// Drop the autosave (nothing is unsaved any more).
pub fn clear(&mut self) {
if self.has_entry {
for ext in ["json", EXTENSION] {
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
}
self.has_entry = false;
}
}
/// The app is closing: the lock is released; with no autosave the lock file goes too, while
/// an autosave stays for the next launch to offer.
pub fn close(mut self) {
self.lock = None;
if !self.has_entry {
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("modelcraft-recovery-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
d
}
#[test]
fn a_crashed_apps_autosave_is_offered_and_a_running_ones_is_not() {
let d = dir("crash");
let mut a = Slot::new(&d).unwrap();
a.write(b"model bytes", Some("/m/part.modelcraft"), 8, 1).unwrap();
assert!(orphans(&d).is_empty(), "held by a running app");
assert!(remove(&d, a.id()).is_err(), "a running app's entry stays");
let id = a.id().to_string();
// The app dies without closing (the lock goes with the process).
drop(a);
let found = orphans(&d);
assert_eq!(found.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [id.as_str()]);
let (bytes, entry) = load(&d, &id).unwrap();
assert_eq!(bytes, b"model bytes");
assert_eq!((entry.path.as_deref(), entry.nodes, entry.elements), (Some("/m/part.modelcraft"), 8, 1));
// A new app gets its own entry and still sees the crashed one.
let b = Slot::new(&d).unwrap();
assert_ne!(b.id(), id);
assert_eq!(orphans(&d).len(), 1);
remove(&d, &id).unwrap();
assert!(orphans(&d).is_empty());
b.close();
assert_eq!(fs::read_dir(&d).unwrap().count(), 0, "a clean close with nothing unsaved leaves nothing");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn clearing_drops_the_autosave_and_closing_with_one_keeps_it() {
let d = dir("clear");
let mut a = Slot::new(&d).unwrap();
a.write(b"x", None, 0, 0).unwrap();
a.clear();
assert!(!a.has_entry() && !file(&d, a.id(), EXTENSION).exists());
a.write(b"y", None, 0, 0).unwrap();
a.close();
assert_eq!(orphans(&d).len(), 1);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn hostile_ids_and_entries_are_refused() {
let d = dir("hostile");
fs::create_dir_all(&d).unwrap();
for id in ["", "../etc/passwd", "a/b", &"x".repeat(65), "a.b"] {
assert!(load(&d, id).is_err(), "{id}");
assert!(remove(&d, id).is_err(), "{id}");
}
// An entry naming another id, junk JSON and a huge description are not listed.
fs::write(d.join("1-1-1.json"), br#"{"id": "2-2-2", "path": null, "saved_at": 1, "nodes": 0, "elements": 0}"#).unwrap();
fs::write(d.join("1-1-1.modelcraft"), b"x").unwrap();
fs::write(d.join("2-2-2.modelcraft"), b"x").unwrap();
fs::write(d.join("3-3-3.json"), b"{not json").unwrap();
fs::write(d.join("4-4-4.json"), vec![b' '; (MAX_ENTRY_BYTES + 1) as usize]).unwrap();
fs::write(d.join("4-4-4.modelcraft"), b"x").unwrap();
assert!(orphans(&d).is_empty());
assert!(load(&d, "1-1-1").is_err());
let _ = fs::remove_dir_all(&d);
}
}