ifc_lite_server/routes/parse/mod.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Parse endpoints for IFC file processing.
6
7pub(crate) mod cache_keys;
8mod cached_replay;
9mod stream_batch;
10mod stream_event;
11mod stream_progress;
12mod fetch;
13mod json;
14mod parquet;
15mod parquet_optimized;
16mod parquet_optimized_replay;
17mod parquet_stream;
18mod replay_header;
19
20pub use fetch::{check_cache, get_cached_geometry, get_data_model, get_symbolic};
21pub use json::{parse_full, parse_metadata, parse_stream};
22pub use parquet::parse_parquet;
23pub use parquet_optimized::parse_parquet_optimized;
24pub use parquet_stream::parse_parquet_stream;
25
26use crate::error::ApiError;
27use crate::services::cache::DiskCache;
28use crate::services::{DataModelEntities, OpeningFilterMode, ParquetLayout, StreamShapes};
29use axum::extract::Multipart;
30use flate2::read::GzDecoder;
31use ifc_lite_processing::{SymbolicDataWithProvenance, TessellationQuality};
32use std::io::{Cursor, Read};
33use std::sync::Arc;
34
35/// Query parameters shared by all parse endpoints.
36#[derive(serde::Deserialize, Default)]
37pub struct ParseQuery {
38 /// Opening filter mode: "default", "ignore_all", or "ignore_opaque".
39 #[serde(default)]
40 pub opening_filter: OpeningFilterMode,
41 /// Tessellation detail level (#976): "lowest" | "low" | "medium" | "high"
42 /// | "highest". Omitted = "medium" (byte-identical to the historical
43 /// output — and to what the wasm path produces without
44 /// `setTessellationQuality`, keeping client and server meshes in parity).
45 #[serde(default)]
46 pub tessellation_quality: Option<String>,
47 /// Flat-Parquet mesh-table layout (#3888): "flat" (default) or
48 /// "shared-shapes" — see [`ParquetLayout`] for why it is opt-in. A query
49 /// parameter rather than a header because every endpoint it has to reach
50 /// (both parse routes, the cache check, the cached-geometry fetch) already
51 /// takes this struct, so the signal travels with the cache identity.
52 #[serde(default)]
53 pub parquet_layout: ParquetLayout,
54 /// Whether `POST /api/v1/parse/parquet-stream` may share shapes ACROSS
55 /// batches (#5407): "batch-local" (default) or "cross-batch", which needs
56 /// `parquet_layout=shared-shapes`. See [`StreamShapes`] for why it is a
57 /// second opt-in rather than implied by the layout. Ignored by every other
58 /// route, and not part of the cache identity.
59 #[serde(default)]
60 pub stream_shapes: StreamShapes,
61 /// Which rows the data model's entities table carries (#6034): "all"
62 /// (default, every STEP instance) or "rooted" (objects with a GlobalId,
63 /// plus every instance another data-model table references). See
64 /// [`DataModelEntities`]. Read by every route that writes the data model
65 /// (`/parse/parquet`, `/parse/parquet/optimized`, `/parse/parquet-stream`)
66 /// and by `/cache/check`, because it selects WHICH data-model entry has to
67 /// exist; the geometry and metadata entries are the same for both.
68 #[serde(default)]
69 pub data_model_entities: DataModelEntities,
70 /// SHA-256 of the file the client is asking about, hex, lowercase (#3901).
71 ///
72 /// Read by `POST /api/v1/parse/parquet-stream` and (since #5128)
73 /// `POST /api/v1/parse/parquet/optimized`, and only when the request
74 /// carries no multipart body: see [`cached_replay::replay_by_client_hash`]
75 /// and [`parquet_optimized_replay::replay_optimized_by_client_hash`] for
76 /// what each does and what it is not allowed to do. It lives on this
77 /// struct rather than in a header so it travels with the rest of the cache
78 /// identity (`opening_filter`, `tessellation_quality`, `parquet_layout`)
79 /// through the one place every parse route already parses. A hash paired
80 /// with the wrong layout names a different entry, and splitting one
81 /// identity across two transports is how such pairings drift apart.
82 #[serde(default)]
83 pub sha256: Option<String>,
84}
85
86impl ParseQuery {
87 /// The stream's batch-sharing mode, refusing cross-batch sharing on the
88 /// flat layout: that layout has no rotation columns and must stay
89 /// byte-identical to v5, so it cannot share anything, and a client asking
90 /// for it has misread the contract.
91 fn resolved_stream_shapes(&self) -> Result<StreamShapes, ApiError> {
92 if self.stream_shapes == StreamShapes::CrossBatch && !self.parquet_layout.has_rotation() {
93 return Err(ApiError::BadRequest(
94 "stream_shapes=cross-batch requires parquet_layout=shared-shapes".to_string(),
95 ));
96 }
97 Ok(self.stream_shapes)
98 }
99
100 /// Resolve and validate the requested tessellation level.
101 fn resolved_tessellation_quality(&self) -> Result<TessellationQuality, ApiError> {
102 match self.tessellation_quality.as_deref() {
103 None => Ok(TessellationQuality::default()),
104 Some(s) => TessellationQuality::parse_label(s).ok_or_else(|| {
105 ApiError::BadRequest(format!(
106 "Unknown tessellation_quality '{s}' — expected lowest | low | medium | high | highest"
107 ))
108 }),
109 }
110 }
111}
112
113/// Write the symbolic sidecar through [`cache_keys::cache_symbolic_data`] on
114/// the blocking pool (#4696).
115///
116/// That call JSON-encodes the whole 2D symbol stream before it writes, and
117/// awaited on an async worker the encode holds that worker for its whole
118/// length. Every parse route writes the sidecar through here. The write itself
119/// is async, so the blocking thread drives it with `Handle::block_on`.
120async fn cache_symbolic_data_off_runtime(
121 cache: Arc<DiskCache>,
122 cache_key: String,
123 symbolic: SymbolicDataWithProvenance,
124) {
125 let runtime = tokio::runtime::Handle::current();
126 let written = tokio::task::spawn_blocking(move || {
127 runtime.block_on(cache_keys::cache_symbolic_data(&cache, &cache_key, &symbolic))
128 })
129 .await;
130 if let Err(e) = written {
131 tracing::error!(error = %e, "Symbolic data cache task failed");
132 }
133}
134
135/// Extract file data from multipart request.
136/// Automatically decompresses gzip-compressed files, refusing inputs whose
137/// decompressed size would exceed `max_file_size_mb`.
138///
139/// Reads the `file` field one chunk at a time (rather than buffering the
140/// whole field via `Field::bytes()`) so an oversized upload is caught and
141/// rejected — with a clean `FileTooLarge` -> 413, logged in
142/// `ApiError::into_response` — as soon as the running total crosses
143/// `max_file_size_mb`, instead of only after the entire body has been
144/// received. This also means the raw framework body limit
145/// (`DefaultBodyLimit`, set well above this ceiling in `main.rs`) is a
146/// defense-in-depth backstop, not the thing actually enforcing the limit.
147pub(crate) async fn extract_file(
148 multipart: &mut Multipart,
149 max_file_size_mb: usize,
150) -> Result<bytes::Bytes, ApiError> {
151 let max_bytes = max_file_size_mb.saturating_mul(1024 * 1024);
152
153 while let Some(mut field) = multipart.next_field().await? {
154 let field_name = field.name().unwrap_or_default();
155 tracing::debug!(field_name = %field_name, "Processing multipart field");
156
157 if field_name == "file" {
158 let mut buf = Vec::new();
159 while let Some(chunk) = field.chunk().await? {
160 if buf.len() + chunk.len() > max_bytes {
161 return Err(ApiError::FileTooLarge {
162 max_mb: max_file_size_mb,
163 });
164 }
165 buf.extend_from_slice(&chunk);
166 }
167 let bytes = bytes::Bytes::from(buf);
168 let original_size = bytes.len();
169 tracing::debug!(size = original_size, "Extracted file from multipart");
170
171 // Check if file is gzip-compressed (magic bytes: 1f 8b)
172 let is_gzipped = bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b;
173 // ...or a zip container (.ifcZIP; local-file-header magic: 50 4b 03 04).
174 let is_zip = bytes.len() >= 4
175 && bytes[0] == 0x50
176 && bytes[1] == 0x4b
177 && bytes[2] == 0x03
178 && bytes[3] == 0x04;
179
180 if is_zip {
181 tracing::debug!("Detected .ifcZIP container, unwrapping...");
182 return unwrap_ifczip(&bytes, max_bytes, max_file_size_mb);
183 }
184
185 if is_gzipped {
186 tracing::debug!("Detected gzip compression, decompressing...");
187 // Bound the decompressed stream: read at most max_bytes + 1.
188 // If the cap is hit, treat as oversized rather than allocating
189 // unbounded output for a small compressed input.
190 let mut decoder = GzDecoder::new(bytes.as_ref()).take(max_bytes as u64 + 1);
191 let mut decompressed = Vec::new();
192 decoder
193 .read_to_end(&mut decompressed)
194 .map_err(|e| ApiError::Internal(format!("Failed to decompress gzip: {}", e)))?;
195 if decompressed.len() > max_bytes {
196 return Err(ApiError::FileTooLarge {
197 max_mb: max_file_size_mb,
198 });
199 }
200 tracing::info!(
201 original_size = original_size,
202 decompressed_size = decompressed.len(),
203 compression_ratio =
204 format!("{:.1}x", original_size as f64 / decompressed.len() as f64),
205 "File decompressed successfully"
206 );
207 return Ok(bytes::Bytes::from(decompressed));
208 } else {
209 // The chunked read loop above already enforces max_bytes.
210 return Ok(bytes);
211 }
212 }
213 }
214
215 tracing::warn!("No 'file' field found in multipart request");
216 Err(ApiError::MissingFile)
217}
218
219/// Unwrap a buildingSMART `.ifcZIP` container (issue #1494): a plain zip
220/// archive wrapping a single `.ifc`/`.ifcxml` model file (optionally alongside
221/// referenced resources like textures — those are ignored, not extracted).
222/// Returns the model entry's bytes so the rest of the pipeline never has to
223/// know zip existed. Mirrors the TypeScript `unwrapIfcZip` semantics
224/// (`packages/parser/src/ifczip.ts`): rejects an archive with zero or more than
225/// one candidate rather than silently guessing which model to load, and bounds
226/// the decompressed size (zip-bomb guard) against the same `max_bytes` ceiling
227/// the raw/gzip paths use.
228/// Whether an archive entry is a macOS AppleDouble sidecar rather than content.
229///
230/// Compressing in macOS Finder writes `__MACOSX/._<name>` beside each entry,
231/// carrying resource forks and extended attributes. It keeps the original
232/// extension, so `__MACOSX/._model.ifc` counted as a second model and every
233/// Mac-made archive was rejected as ambiguous (#2812, reported from
234/// production).
235///
236/// The test is the BASENAME, not the directory. `._` is what makes a file a
237/// sidecar; `__MACOSX/` is merely where macOS puts them, so matching on it is
238/// redundant (every entry inside is already `._`-prefixed) and wrong for a user
239/// whose archive genuinely contains a folder of that name. The basename form
240/// also covers a sidecar left beside its original by a rezip that flattens the
241/// directory away.
242///
243/// Mirrors `APPLE_DOUBLE_RE` in `packages/parser/src/ifczip.ts`. The two must
244/// agree, or an archive the browser accepts is rejected by the server.
245fn is_apple_double(name: &str) -> bool {
246 name.rsplit('/')
247 .next()
248 .is_some_and(|base| base.starts_with("._"))
249}
250
251fn unwrap_ifczip(
252 bytes: &[u8],
253 max_bytes: usize,
254 max_file_size_mb: usize,
255) -> Result<bytes::Bytes, ApiError> {
256 let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
257 .map_err(|e| ApiError::BadRequest(format!("Failed to read .ifcZIP archive: {e}")))?;
258
259 // Collect the model-file entries (case-insensitive .ifc/.ifcxml, non-dir).
260 // Owned names so the >1 error can list them without holding a borrow of the
261 // archive across iterations.
262 let mut candidates: Vec<(usize, String)> = Vec::new();
263 for i in 0..archive.len() {
264 let entry = archive
265 .by_index(i)
266 .map_err(|e| ApiError::BadRequest(format!("Corrupt .ifcZIP entry: {e}")))?;
267 if entry.is_dir() {
268 continue;
269 }
270 let name = entry.name();
271 let lower = name.to_ascii_lowercase();
272 if (lower.ends_with(".ifc") || lower.ends_with(".ifcxml")) && !is_apple_double(name) {
273 candidates.push((i, name.to_string()));
274 }
275 }
276
277 match candidates.len() {
278 0 => {
279 return Err(ApiError::BadRequest(
280 "This .ifcZIP archive contains no .ifc/.ifcxml entry — nothing to parse."
281 .to_string(),
282 ))
283 }
284 1 => {}
285 n => {
286 let names = candidates
287 .iter()
288 .map(|(_, name)| name.as_str())
289 .collect::<Vec<_>>()
290 .join(", ");
291 return Err(ApiError::BadRequest(format!(
292 "This .ifcZIP archive contains {n} model files ({names}) — expected exactly one."
293 )));
294 }
295 }
296
297 let index = candidates[0].0;
298 let mut entry = archive
299 .by_index(index)
300 .map_err(|e| ApiError::BadRequest(format!("Corrupt .ifcZIP entry: {e}")))?;
301
302 // Reject up front on the uncompressed size declared in the central
303 // directory (no decompression yet), then bound the actual read as a
304 // belt-and-braces guard against a lying header — same shape as the gzip
305 // path above.
306 if entry.size() > max_bytes as u64 {
307 return Err(ApiError::FileTooLarge {
308 max_mb: max_file_size_mb,
309 });
310 }
311
312 let mut model = Vec::new();
313 entry
314 .by_ref()
315 .take(max_bytes as u64 + 1)
316 .read_to_end(&mut model)
317 .map_err(|e| ApiError::Internal(format!("Failed to decompress .ifcZIP entry: {e}")))?;
318 if model.len() > max_bytes {
319 return Err(ApiError::FileTooLarge {
320 max_mb: max_file_size_mb,
321 });
322 }
323
324 tracing::info!(
325 entry = %candidates[0].1,
326 compressed_size = bytes.len(),
327 model_size = model.len(),
328 "Unwrapped .ifcZIP container"
329 );
330 Ok(bytes::Bytes::from(model))
331}
332
333#[cfg(test)]
334mod extract_file_tests;
335
336#[cfg(test)]
337mod ifczip_tests;
338
339#[cfg(test)]
340mod parquet_tests;
341
342#[cfg(test)]
343mod parquet_optimized_tests;
344
345#[cfg(test)]
346mod json_tests;
347
348#[cfg(test)]
349mod finish_tests;
350
351#[cfg(test)]
352mod worker_thread_tests;
353
354#[cfg(test)]
355mod fetch_tests;
356
357#[cfg(test)]
358mod cache_keys_symbolic_tests;
359
360#[cfg(test)]
361mod cache_keys_tests;
362
363#[cfg(test)]
364mod data_model_entities_tests;
365
366#[cfg(test)]
367mod cached_replay_tests;
368
369#[cfg(test)]
370mod cached_replay_batches_tests;
371
372#[cfg(test)]
373#[path = "resolved_tessellation_quality_tests.rs"]
374mod resolved_tessellation_quality_tests;
375
376#[cfg(test)]
377#[path = "apple_double_tests.rs"]
378mod apple_double_tests;