1use std::fs::{self, File};
10use std::io::Read;
11use std::path::Path;
12
13use crate::convert::{ConvertOptions, PageConsumer};
14use crate::document::html::{HtmlBlock, render_blocks_to_pages};
15use crate::error::{Error, Result};
16use crate::table::{TableAlign, TableData};
17
18const MAX_OP2_BYTES: u64 = 2 * 1024 * 1024 * 1024;
19const MAX_OP2_SCAN_BYTES: usize = 16 * 1024 * 1024;
20const MAX_OP2_RECORD_BYTES: usize = 64 * 1024 * 1024;
21const MAX_OP2_TABLES: usize = 1000;
22
23const TABLE_NAMES: &[&[u8]] = &[
24 b"GEOM1",
25 b"GEOM2",
26 b"GEOM3",
27 b"GEOM4",
28 b"GEOMM1",
29 b"OUGV1",
30 b"OES1",
31 b"OQG1",
32 b"OGPWG",
33 b"OEF1",
34 b"OAG1",
35 b"OPG1",
36 b"MAT1",
37 b"KELM",
38 b"XSOP2DIR",
39];
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42enum Endian {
43 Little,
44 Big,
45}
46
47impl Endian {
48 fn name(self) -> &'static str {
49 match self {
50 Self::Little => "little-endian",
51 Self::Big => "big-endian",
52 }
53 }
54 fn u32(self, bytes: &[u8]) -> Option<u32> {
55 let array: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
56 Some(match self {
57 Self::Little => u32::from_le_bytes(array),
58 Self::Big => u32::from_be_bytes(array),
59 })
60 }
61}
62
63struct Op2PageSink<'a> {
64 inner: &'a mut dyn PageConsumer,
65 warnings: &'a [String],
66}
67
68impl PageConsumer for Op2PageSink<'_> {
69 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
70 page.source_format = "op2".into();
71 if page.title.is_empty() {
72 page.title = "Nastran OP2 preflight".into();
73 }
74 page.description = "Nastran Output2 record metadata is rendered as bounded inert rows; model/result payloads are not decoded".into();
75 for warning in self.warnings {
76 page.warn(warning.clone());
77 }
78 self.inner.consume(page)
79 }
80}
81
82pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
83 TABLE_NAMES
84 .iter()
85 .any(|name| prefix.windows(name.len()).any(|window| window == *name))
86 && detect_endian(prefix).is_some()
87}
88
89pub(crate) fn convert(
90 path: &Path,
91 options: &ConvertOptions,
92 sink: &mut dyn PageConsumer,
93) -> Result<Vec<String>> {
94 let metadata = fs::metadata(path)?;
95 let max_bytes = options.max_input_bytes.min(MAX_OP2_BYTES);
96 if metadata.len() > max_bytes {
97 return Err(Error::LimitExceeded(format!(
98 "OP2 input exceeds maximum bytes ({max_bytes})"
99 )));
100 }
101 let scan_len = metadata.len().min(MAX_OP2_SCAN_BYTES as u64) as usize;
102 let mut file = File::open(path)?;
103 let mut bytes = vec![0u8; scan_len];
104 file.read_exact(&mut bytes)?;
105 let endian = detect_endian(&bytes).ok_or_else(|| {
106 Error::InvalidInput("OP2 record framing was not recognized in the bounded prefix".into())
107 })?;
108 let (records, scanned_bytes) = scan_records(&bytes, endian);
109 let mut tables = Vec::new();
110 for name in TABLE_NAMES {
111 if bytes.windows(name.len()).any(|window| window == *name) {
112 tables.push(std::str::from_utf8(name).unwrap_or("table"));
113 if tables.len() >= MAX_OP2_TABLES {
114 break;
115 }
116 }
117 }
118 let table_text = if tables.is_empty() {
119 "none recognized".into()
120 } else {
121 tables.join(", ")
122 };
123 let rows = vec![
124 vec!["File bytes".into(), metadata.len().to_string()],
125 vec!["Scan bytes".into(), scanned_bytes.to_string()],
126 vec!["Record byte order".into(), endian.name().into()],
127 vec!["Framed records".into(), records.to_string()],
128 vec!["Recognized tables".into(), table_text],
129 ];
130 let mut warnings = vec![
131 "OP2 table schemas, model geometry, element connectivity, result vectors, precision variants, and subcase semantics are not decoded".into(),
132 "External references, solver commands, DMAP/user code, file paths, and result post-processing remain inert and are never executed".into(),
133 ];
134 if scan_len < metadata.len() as usize {
135 warnings.push(format!(
136 "OP2 preflight scanned only the first {MAX_OP2_SCAN_BYTES} bytes"
137 ));
138 }
139 let blocks = vec![
140 HtmlBlock::Heading {
141 level: 1,
142 text: "Nastran OP2 preflight".into(),
143 },
144 HtmlBlock::Paragraph {
145 text: "The bounded prefix is checked for conservative OP2 record framing and known table labels. Binary model and result payloads are never expanded.".into(),
146 },
147 HtmlBlock::Table(TableData {
148 headers: vec!["Metric".into(), "Value".into()],
149 rows,
150 alignments: vec![TableAlign::Left, TableAlign::Right],
151 raw_source: String::new(),
152 }),
153 ];
154 let mut page_sink = Op2PageSink {
155 inner: sink,
156 warnings: &warnings,
157 };
158 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
159 warnings.sort();
160 warnings.dedup();
161 Ok(warnings)
162}
163
164fn detect_endian(bytes: &[u8]) -> Option<Endian> {
165 [Endian::Little, Endian::Big].into_iter().find(|endian| {
166 (0..bytes.len().saturating_sub(12))
167 .step_by(4)
168 .take(256)
169 .any(|offset| valid_record(bytes, *endian, offset).is_some())
170 })
171}
172
173fn valid_record(bytes: &[u8], endian: Endian, offset: usize) -> Option<usize> {
174 let record_len = usize::try_from(endian.u32(bytes.get(offset..offset + 4)?)?).ok()?;
175 if record_len == 0 || record_len > MAX_OP2_RECORD_BYTES {
176 return None;
177 }
178 let trailer_offset = offset.checked_add(4)?.checked_add(record_len)?;
179 let trailer = endian.u32(bytes.get(trailer_offset..trailer_offset + 4)?)?;
180 (trailer as usize == record_len).then_some(record_len + 8)
181}
182
183fn scan_records(bytes: &[u8], endian: Endian) -> (usize, usize) {
184 let mut offset = 0usize;
185 let mut records = 0usize;
186 while offset.saturating_add(8) <= bytes.len() && records < MAX_OP2_TABLES * 100 {
187 if let Some(consumed) = valid_record(bytes, endian, offset) {
188 records = records.saturating_add(1);
189 offset = offset.saturating_add(consumed);
190 } else {
191 offset = offset.saturating_add(4);
192 }
193 }
194 (records, offset.min(bytes.len()))
195}