forked from 1b-agent/fieldcraft-upstream
* io: read and write roots that confine an automation client's file access Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: every file a command reads or writes is checked against the session's roots Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: headless pictures of a session (model, geometry, deformed shape and contours) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mcp: MCP server over stdio with headless and connected backends, roots and caps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * cli: modelcraft-cli mcp [--connect ADDR | --headless] with automation read and write roots Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mcp: end-to-end analysis of a corpus part over MCP (open, tet mesh, define, solve, probe, render) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: MCP server (docs/mcp.md), Claude Code setup, roots; architecture, README, roadmap, attribution Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
15 KiB
Rust
368 lines
15 KiB
Rust
//! 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`],
|
|
//! [`recovery`] and [`roots`] 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;
|
|
pub mod roots;
|
|
|
|
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),
|
|
/// 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: 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()
|
|
}
|