Read design metadata and previews through a bounded seekable archive (#462)

This commit is contained in:
selune authored and GitHub committed 2026-10-12 21:27:49 -04:00
1 parent 9ab25cfd4a
commit b24e478bc1
3 files changed
+197 -15

No files matched your search

+136 -13
View File
@@ -1,7 +1,7 @@
//! Versioned design packages. Geometry is rebuilt from authoritative inputs; no
//! evaluated cache is trusted here. All archive access stays in memory, never on disk.
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
@@ -219,18 +219,25 @@ fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
validate_mesh(mesh)
}
struct PackageReader<'a> {
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
struct PackageReader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
hydrated: usize,
}
impl<'a> PackageReader<'a> {
fn new(bytes: &'a [u8]) -> Result<Self> {
impl<R: Read + Seek> PackageReader<R> {
fn new(mut source: R) -> Result<Self> {
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
if length > MAX_DESIGN_BYTES as u64 {
return Err(invalid("compressed file too large"));
}
let tail_length = length.min(65_557) as usize;
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
let mut tail = vec![0; tail_length];
source.read_exact(&mut tail).map_err(invalid)?;
// zip's name index can coalesce duplicate central-directory entries. Our
// bounded format does not need ZIP64/multiple disks: verify the declared
// entry count as well so duplicate names cannot silently select a payload.
let tail = bytes.get(bytes.len().saturating_sub(65_557)..).ok_or_else(|| invalid("missing ZIP directory"))?;
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
let u16_at = |offset| -> Result<u16> {
@@ -241,7 +248,7 @@ impl<'a> PackageReader<'a> {
if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize {
return Err(invalid("too many entries or unsupported ZIP directory"));
}
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
let mut zip = zip::ZipArchive::new(source).map_err(invalid)?;
if u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != zip.len()
@@ -342,11 +349,10 @@ pub fn read_design_preview(bytes: &[u8]) -> Option<DesignPreview> {
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
return None;
}
read_preview(bytes).ok()
DesignContainerReader::new(Cursor::new(bytes)).ok()?.preview()
}
fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
let mut reader = PackageReader::new(bytes)?;
fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignPreview> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if !matches!(container.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
@@ -398,8 +404,38 @@ fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
Ok(preview)
}
/// A bounded container reader over an immutable, seekable source.
///
/// Opening inspects the directory, not the source blobs. `preview` reads only the
/// optional display entries and document metadata; `read_document` then resolves
/// and validates authoritative blobs. Keep the source unchanged for this reader's
/// lifetime. This type does not turn untrusted cached data into editable geometry.
pub struct DesignContainerReader<R: Read + Seek> {
reader: PackageReader<R>,
}
impl<R: Read + Seek> DesignContainerReader<R> {
/// Open a container (not plain JSON), enforcing the same budgets as byte IO.
pub fn new(source: R) -> Result<Self> {
Ok(Self { reader: PackageReader::new(source)? })
}
/// Optional display-only data. Corruption or incompatibility is a cache miss.
pub fn preview(&mut self) -> Option<DesignPreview> {
read_preview(&mut self.reader).ok()
}
/// Consume the reader, resolving every required blob before returning a document.
pub fn read_document(mut self) -> Result<Document> {
read_document(&mut self.reader)
}
}
pub(super) fn read(bytes: &[u8]) -> Result<Document> {
let mut reader = PackageReader::new(bytes)?;
DesignContainerReader::new(Cursor::new(bytes))?.read_document()
}
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
@@ -450,6 +486,85 @@ mod tests {
use super::*;
use solidcraft_geom::Vec2;
struct CountingReader {
source: Cursor<Vec<u8>>,
count: std::rc::Rc<std::cell::Cell<usize>>,
}
impl Read for CountingReader {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
let count = self.source.read(bytes)?;
self.count.set(self.count.get() + count);
Ok(count)
}
}
impl Seek for CountingReader {
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
self.source.seek(position)
}
}
#[test]
fn seekable_reader_rejects_oversized_source_before_reading() {
struct Oversized;
impl Read for Oversized {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
panic!("oversized input must be rejected before any allocation or read");
}
}
impl Seek for Oversized {
fn seek(&mut self, _: SeekFrom) -> std::io::Result<u64> {
Ok(MAX_DESIGN_BYTES as u64 + 1)
}
}
assert!(DesignContainerReader::new(Oversized).is_err());
}
#[test]
fn seekable_preview_defers_large_source_blob_until_document_read() {
let mut doc = Document::new("Deferred source");
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
let preview = DesignPreview { meshes: vec![mesh()] };
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let count = std::rc::Rc::new(std::cell::Cell::new(0));
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
let mut reader = DesignContainerReader::new(source).unwrap();
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
// ZIP directory scans may touch a bounded suffix of a stored blob. They
// must not load its multi-megabyte payload to obtain the saved picture.
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
let restored = reader.read_document().unwrap();
assert_eq!(restored.to_json(), doc.to_json());
assert!(count.get() >= step.len());
}
#[test]
fn seekable_reader_validates_required_blobs_after_preview() {
let doc = document();
let preview = DesignPreview { meshes: vec![mesh()] };
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
let marker = b"ISO-10303-21;";
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
bytes[offset] ^= 1;
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
assert!(reader.preview().is_some());
assert!(reader.read_document().is_err());
}
#[test]
fn seekable_reader_without_preview_preserves_authoritative_document() {
let doc = document();
let bytes = write_design_container(&doc).unwrap();
let mut source = Cursor::new(bytes);
source.set_position(17); // Constructor starts from the whole source, not its cursor.
let mut reader = DesignContainerReader::new(source).unwrap();
assert!(reader.preview().is_none());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
}
fn mesh() -> MeshData {
MeshData {
name: "Precise mesh".into(),
@@ -676,7 +791,7 @@ mod tests {
let mut entries = parts(&bytes);
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
let reference = metadata["features"][0].clone();
let mut reader = PackageReader::new(&bytes).unwrap();
let mut reader = PackageReader::new(Cursor::new(&bytes)).unwrap();
reader.hydrated = MAX_EXPANDED;
assert!(reader.blob(&reference, "step_blob", "step").unwrap_err().to_string().contains("resolved payloads too large"));
for name in ["../escape.step", "blobs/not-a-hash.step", "blobs/00.png"] {
@@ -696,6 +811,10 @@ mod tests {
std::panic::catch_unwind(|| {
let _ = crate::read_design(&bytes[..end]);
let _ = read_design_preview(&bytes[..end]);
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&bytes[..end])) {
let _ = reader.preview();
let _ = reader.read_document();
}
})
.is_ok(),
"truncated at {end}"
@@ -713,6 +832,10 @@ mod tests {
std::panic::catch_unwind(|| {
let _ = crate::read_design(&changed);
let _ = read_design_preview(&changed);
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&changed)) {
let _ = reader.preview();
let _ = reader.read_document();
}
})
.is_ok()
);
+2 -2
View File
@@ -10,8 +10,8 @@ mod design;
mod design_metadata;
mod dxf;
pub use design::{
DesignCompression, DesignPreview, is_design_container, read_design_preview, write_design_container, write_design_container_with_compression,
write_design_container_with_preview,
DesignCompression, DesignContainerReader, DesignPreview, is_design_container, read_design_preview, write_design_container,
write_design_container_with_compression, write_design_container_with_preview,
};
mod legacy_dir;
pub mod safe_write;
+59
View File
@@ -89,3 +89,62 @@ any change of active document/revision discards both preview and completion.
Normal design reading, CLI and browser opening still rebuild authoritatively; no
evaluated B-rep is deserialized or adopted. Portable evaluated B-rep/state restoration
and interactive cached viewport navigation remain further work in #303.
## Seekable reader (phase 2, first increment)
`io::DesignContainerReader<R: Read + Seek>` accepts a container source such as a
file or a memory cursor. It inspects a bounded ZIP directory suffix and entry
headers, then `preview()` reads the optional display entries and document metadata.
`read_document()` consumes the reader and resolves and validates the required
source blobs through the same archive. No complete archive buffer is required.
The existing byte-based readers use this implementation too; JSON keeps its existing
reader. This increment provides the IO API; desktop and browser integration remain
separate. It does not yet change application opening times or saving behavior.
The source must remain unchanged for the lifetime of the reader. A file handle is
not an immutable snapshot of concurrent in-place writes: application integration
must address that before publishing a cached view. Optional preview success never
validates source blobs or makes a document editable. An invalid required blob still
fails the later document read. ZIP directory scans can touch a bounded suffix of a
stored blob; deferral means not loading or decoding the complete payload, not zero
physical reads of its byte range. Metadata retains the existing part-size budget.
A counting-reader regression uses a 2.6 MB stored source blob and obtains the saved
preview with fewer than 256 KiB read, then verifies the authoritative document is
identical after resolving its source. Corrupt-source and preview-miss regressions,
and truncation/seeded mutations through the combined reader, protect the boundary.
This is an IO-volume test, not a geometry or first-frame benchmark.
## Remaining #303 increments and kernel independence
Keep each increment independently reviewable after the phase-one container PR:
1. Seekable IO and its read-volume/hostile-input tests (the API above).
2. A versioned SolidCraft-owned B-rep schema and bounded binary codec, tested without
constructing a kernel body. Topology references, oriented edge uses and face
loops, geometric domains, analytic/spline curve and surface definitions, units,
tolerances and orientation must have explicit semantics. Preserve face-source
certificates, persistent names and history using SolidCraft-owned identities.
No truck serialized structs, memory layouts, pointers or opaque kernel blobs
form part of the persisted contract. No evaluated STEP round trip is a substitute.
3. Separate adapters between the neutral schema and supported kernels. Unsupported
geometry or provenance is a cache miss, never a lossy approximation. A neutral
file schema alone does not prove interoperability: each adapter needs round-trip,
modelling and naming tests; compatibility with a second kernel is unverified
until that adapter exists. Kernel/version information belongs in producer and
capability metadata, not in the topology encoding. Conservative cache invalidation
is allowed across kernels while authoritative document reopening still works.
4. Integrate deferred source reading and provisional B-rep display into the open
worker with immutable-input and document/revision guards. Rebuild authoritatively
before editing, picking or measuring. B-rep alone is not a complete evaluated
session: sketches, construction, placements and other non-solid state still matter.
5. Measure cold first display, time until editing, save latency, size and peak memory
on a public STEP-based design. Include cache hits/misses, plain JSON and browser
fallback, and report baselines and limitations in `docs/perf.md`.
Default saving must remain fast: do not force cold evaluation or meshing merely to
produce a disposable cache. Do not add redundant display meshes by default; the
existing preview remains opt-in. GPU tessellation is independent work. Continue
using format/application constants rather than source-tree hashing build scripts,
retain JSON Save As/MCP/script compatibility and the documented legacy shims, and
run standalone wasm validation on each published code revision.