document_svg/document/
properties.rs1use std::collections::HashMap;
4use std::path::Path;
5
6use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
7use crate::document::html::{HtmlBlock, render_blocks_to_pages};
8use crate::error::{Error, Result};
9
10const MAX_PROPERTIES_BYTES: u64 = 16 * 1024 * 1024;
11const MAX_PROPERTIES_LINES: usize = 100_000;
12const MAX_PROPERTIES_LINE_BYTES: usize = 1024 * 1024;
13const MAX_PROPERTIES_LOGICAL_LINE_BYTES: usize = 2 * 1024 * 1024;
14const MAX_PROPERTIES_ROWS: usize = 200_000;
15const MAX_PROPERTIES_SCALAR_BYTES: usize = 2 * 1024 * 1024;
16const MAX_PROPERTIES_PATH_BYTES: usize = 4 * 1024;
17const MAX_PROPERTIES_RENDERED_BYTES: usize = 32 * 1024 * 1024;
18
19struct PropertyRow {
20 key: String,
21 value: String,
22}
23
24struct PropertiesPageSink<'a> {
25 inner: &'a mut dyn PageConsumer,
26 warnings: &'a [String],
27}
28
29impl PageConsumer for PropertiesPageSink<'_> {
30 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
31 page.source_format = "properties".into();
32 if page.title.is_empty() {
33 page.title = "Java properties".into();
34 }
35 for warning in self.warnings {
36 page.warn(warning.clone());
37 }
38 self.inner.consume(page)
39 }
40}
41
42pub(crate) fn convert(
43 path: &Path,
44 options: &ConvertOptions,
45 sink: &mut dyn PageConsumer,
46) -> Result<Vec<String>> {
47 let bytes = read_limited_file(
48 path,
49 options.max_input_bytes.min(MAX_PROPERTIES_BYTES),
50 "Java properties input",
51 )?;
52 let (rows, warnings) = parse_properties(&bytes)?;
53 if options.max_pages == 0 {
54 return Err(Error::LimitExceeded(
55 "Java properties conversion requires at least one page; max_pages is zero".into(),
56 ));
57 }
58 let blocks = rows_to_blocks(&rows)?;
59 let mut page_sink = PropertiesPageSink {
60 inner: sink,
61 warnings: &warnings,
62 };
63 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
64 Ok(warnings)
65}
66
67fn parse_properties(bytes: &[u8]) -> Result<(Vec<PropertyRow>, Vec<String>)> {
68 if bytes.len() as u64 > MAX_PROPERTIES_BYTES {
69 return Err(Error::LimitExceeded(format!(
70 "Java properties input exceeds {MAX_PROPERTIES_BYTES} bytes"
71 )));
72 }
73
74 let mut rows = Vec::<PropertyRow>::new();
75 let mut key_positions = HashMap::<String, usize>::new();
76 let mut duplicate_count = 0usize;
77 let mut logical_line = String::new();
78 let mut continuing = false;
79 let mut offset = 0usize;
80 let mut physical_lines = 0usize;
81
82 while offset < bytes.len() {
83 let start = offset;
84 while offset < bytes.len() && bytes[offset] != b'\r' && bytes[offset] != b'\n' {
85 offset += 1;
86 }
87 let line = &bytes[start..offset];
88 if offset < bytes.len() {
89 if bytes[offset] == b'\r' && bytes.get(offset + 1) == Some(&b'\n') {
90 offset += 2;
91 } else {
92 offset += 1;
93 }
94 }
95 physical_lines = physical_lines.saturating_add(1);
96 if physical_lines > MAX_PROPERTIES_LINES {
97 return Err(Error::LimitExceeded(format!(
98 "Java properties input exceeds {MAX_PROPERTIES_LINES} physical lines"
99 )));
100 }
101 if line.len() > MAX_PROPERTIES_LINE_BYTES {
102 return Err(Error::LimitExceeded(format!(
103 "Java properties physical line exceeds {MAX_PROPERTIES_LINE_BYTES} bytes"
104 )));
105 }
106
107 let mut decoded = line
108 .iter()
109 .map(|byte| char::from(*byte))
110 .collect::<String>();
111 if continuing {
112 decoded = decoded
113 .trim_start_matches(is_properties_whitespace)
114 .to_owned();
115 logical_line.push_str(&decoded);
116 } else {
117 let significant = decoded.trim_start_matches(is_properties_whitespace);
118 if significant.is_empty()
119 || significant.starts_with('#')
120 || significant.starts_with('!')
121 {
122 continue;
123 }
124 logical_line.push_str(significant);
125 }
126 if logical_line.len() > MAX_PROPERTIES_LOGICAL_LINE_BYTES {
127 return Err(Error::LimitExceeded(format!(
128 "Java properties logical line exceeds {MAX_PROPERTIES_LOGICAL_LINE_BYTES} bytes"
129 )));
130 }
131
132 let trailing_slashes = logical_line
133 .chars()
134 .rev()
135 .take_while(|character| *character == '\\')
136 .count();
137 if trailing_slashes % 2 == 1 {
138 logical_line.pop();
139 continuing = true;
140 continue;
141 }
142
143 continuing = false;
144 insert_entry(
145 &logical_line,
146 &mut rows,
147 &mut key_positions,
148 &mut duplicate_count,
149 )?;
150 logical_line.clear();
151 }
152
153 if continuing {
154 insert_entry(
156 &logical_line,
157 &mut rows,
158 &mut key_positions,
159 &mut duplicate_count,
160 )?;
161 }
162
163 let warnings = if duplicate_count == 0 {
164 Vec::new()
165 } else {
166 vec![format!(
167 "{duplicate_count} duplicate Java properties key(s) use their last value, matching Properties.load"
168 )]
169 };
170 Ok((rows, warnings))
171}
172
173fn insert_entry(
174 logical_line: &str,
175 rows: &mut Vec<PropertyRow>,
176 key_positions: &mut HashMap<String, usize>,
177 duplicate_count: &mut usize,
178) -> Result<()> {
179 let (key, value) = parse_logical_line(logical_line)?;
180 if key.len() > MAX_PROPERTIES_SCALAR_BYTES || value.len() > MAX_PROPERTIES_SCALAR_BYTES {
181 return Err(Error::LimitExceeded(format!(
182 "Java properties key/value exceeds {MAX_PROPERTIES_SCALAR_BYTES} decoded bytes"
183 )));
184 }
185 if let Some(index) = key_positions.get(&key).copied() {
186 rows[index].value = value;
187 *duplicate_count = duplicate_count.saturating_add(1);
188 } else {
189 if rows.len() >= MAX_PROPERTIES_ROWS {
190 return Err(Error::LimitExceeded(format!(
191 "Java properties input exceeds {MAX_PROPERTIES_ROWS} distinct keys"
192 )));
193 }
194 key_positions.insert(key.clone(), rows.len());
195 rows.push(PropertyRow { key, value });
196 }
197 Ok(())
198}
199
200fn parse_logical_line(line: &str) -> Result<(String, String)> {
201 let characters = line.chars().collect::<Vec<_>>();
202 let mut key_end = characters.len();
203 let mut value_start = characters.len();
204 let mut has_separator = false;
205 let mut preceding_backslash = false;
206
207 for (index, character) in characters.iter().copied().enumerate() {
208 if !preceding_backslash && (character == '=' || character == ':') {
209 key_end = index;
210 value_start = index + 1;
211 has_separator = true;
212 break;
213 }
214 if !preceding_backslash && is_properties_whitespace(character) {
215 key_end = index;
216 value_start = index + 1;
217 break;
218 }
219 if character == '\\' {
220 preceding_backslash = !preceding_backslash;
221 } else {
222 preceding_backslash = false;
223 }
224 }
225
226 while value_start < characters.len() {
227 let character = characters[value_start];
228 if is_properties_whitespace(character) {
229 value_start += 1;
230 } else if !has_separator && (character == '=' || character == ':') {
231 has_separator = true;
232 value_start += 1;
233 } else {
234 break;
235 }
236 }
237
238 let key = decode_escapes(&characters[..key_end])?;
239 let value = decode_escapes(&characters[value_start..])?;
240 Ok((key, value))
241}
242
243fn decode_escapes(source: &[char]) -> Result<String> {
244 let mut utf16 = Vec::<u16>::with_capacity(source.len());
245 let mut index = 0usize;
246 while index < source.len() {
247 let character = source[index];
248 index += 1;
249 if character != '\\' {
250 let mut encoded = [0u16; 2];
251 utf16.extend_from_slice(character.encode_utf16(&mut encoded));
252 continue;
253 }
254
255 let Some(escaped) = source.get(index).copied() else {
256 return Err(Error::InvalidInput(
257 "Java properties escape ends at end of logical line".into(),
258 ));
259 };
260 index += 1;
261 match escaped {
262 't' => utf16.push('\t' as u16),
263 'n' => utf16.push('\n' as u16),
264 'r' => utf16.push('\r' as u16),
265 'f' => utf16.push('\u{000c}' as u16),
266 'u' => {
267 if index + 4 > source.len() {
268 return Err(Error::InvalidInput(
269 "Java properties Unicode escape must contain exactly four hex digits"
270 .into(),
271 ));
272 }
273 let mut code_unit = 0u16;
274 for digit in &source[index..index + 4] {
275 let Some(value) = digit.to_digit(16) else {
276 return Err(Error::InvalidInput(
277 "Java properties Unicode escape contains a non-hex digit".into(),
278 ));
279 };
280 code_unit = code_unit * 16 + value as u16;
281 }
282 utf16.push(code_unit);
283 index += 4;
284 }
285 other => {
286 let mut encoded = [0u16; 2];
287 utf16.extend_from_slice(other.encode_utf16(&mut encoded));
288 }
289 }
290 }
291 String::from_utf16(&utf16).map_err(|error| {
292 Error::InvalidInput(format!(
293 "Java properties Unicode escapes do not form valid UTF-16: {error}"
294 ))
295 })
296}
297
298fn is_properties_whitespace(character: char) -> bool {
299 matches!(character, ' ' | '\t' | '\u{000c}')
300}
301
302fn rows_to_blocks(rows: &[PropertyRow]) -> Result<Vec<HtmlBlock>> {
303 let mut blocks = vec![HtmlBlock::Heading {
304 level: 1,
305 text: "Java properties".into(),
306 }];
307 let mut rendered_bytes = 0usize;
308 for row in rows {
309 let path = format!("$[{}]", serde_json::to_string(&row.key)?);
310 if path.len() > MAX_PROPERTIES_PATH_BYTES {
311 return Err(Error::LimitExceeded(format!(
312 "Java properties path exceeds {MAX_PROPERTIES_PATH_BYTES} bytes"
313 )));
314 }
315 let value = serde_json::to_string(&row.value)?;
316 let line = format!("{path} = {value}");
317 rendered_bytes = rendered_bytes.saturating_add(line.len());
318 if rendered_bytes > MAX_PROPERTIES_RENDERED_BYTES {
319 return Err(Error::LimitExceeded(format!(
320 "Java properties preview text exceeds {MAX_PROPERTIES_RENDERED_BYTES} bytes"
321 )));
322 }
323 blocks.push(HtmlBlock::Paragraph { text: line });
324 }
325 Ok(blocks)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn parses_java_properties_comments_escapes_and_continuations() {
334 let source = b"# comment\r\n! another comment\nname = Catalog\naccent=\xC3\xA9\nkey\\:with\\=separators: value\ncontinued=first\\\n second\nemoji=\\uD83D\\uDE80\n";
335 let (rows, warnings) = parse_properties(source).unwrap();
336 let values = rows
337 .into_iter()
338 .map(|row| (row.key, row.value))
339 .collect::<HashMap<_, _>>();
340 assert_eq!(values.get("name").map(String::as_str), Some("Catalog"));
341 assert_eq!(values.get("accent").map(String::as_str), Some("é"));
342 assert_eq!(
343 values.get("key:with=separators").map(String::as_str),
344 Some("value")
345 );
346 assert_eq!(
347 values.get("continued").map(String::as_str),
348 Some("firstsecond")
349 );
350 assert_eq!(values.get("emoji").map(String::as_str), Some("🚀"));
351 assert!(warnings.is_empty());
352 }
353
354 #[test]
355 fn duplicate_keys_use_last_value_and_malformed_unicode_fails() {
356 let (rows, warnings) = parse_properties(b"name=first\nname=last\n").unwrap();
357 assert_eq!(rows.len(), 1);
358 assert_eq!(rows[0].value, "last");
359 assert!(warnings.iter().any(|warning| warning.contains("duplicate")));
360 assert!(parse_properties(b"value=\\u12xz\n").is_err());
361 assert!(parse_properties(b"value=\\uD800\n").is_err());
362 let (rows, _) = parse_properties(b"unfinished=kept\\").unwrap();
363 assert_eq!(rows[0].value, "kept");
364 }
365
366 #[test]
367 fn bounds_input_lines_and_rows() {
368 assert!(matches!(
369 parse_properties(&vec![b'x'; MAX_PROPERTIES_BYTES as usize + 1]),
370 Err(Error::LimitExceeded(_))
371 ));
372 let too_long = vec![b'x'; MAX_PROPERTIES_LINE_BYTES + 1];
373 assert!(matches!(
374 parse_properties(&too_long),
375 Err(Error::LimitExceeded(_))
376 ));
377 }
378}