Skip to main content

document_svg/document/
properties.rs

1//! Bounded, inert previews for Java-style `.properties` files.
2
3use 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        // OpenJDK's Properties line reader drops the final continuation slash at EOF.
155        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}