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;