imprimatur 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/Cargo.lock +835 -0
- data/Cargo.toml +44 -0
- data/LICENSE.txt +29 -0
- data/README.md +314 -0
- data/ext/imprimatur_native/cli/Cargo.toml +30 -0
- data/ext/imprimatur_native/cli/src/app.rs +183 -0
- data/ext/imprimatur_native/cli/src/cli/completion.rs +26 -0
- data/ext/imprimatur_native/cli/src/cli/grep.rs +359 -0
- data/ext/imprimatur_native/cli/src/cli/images.rs +137 -0
- data/ext/imprimatur_native/cli/src/cli/limits.rs +49 -0
- data/ext/imprimatur_native/cli/src/cli/markdown.rs +128 -0
- data/ext/imprimatur_native/cli/src/cli.rs +71 -0
- data/ext/imprimatur_native/cli/src/main.rs +15 -0
- data/ext/imprimatur_native/core/Cargo.toml +33 -0
- data/ext/imprimatur_native/core/LICENSE +29 -0
- data/ext/imprimatur_native/core/README.md +105 -0
- data/ext/imprimatur_native/core/src/doc/paragraph.rs +112 -0
- data/ext/imprimatur_native/core/src/doc/table.rs +41 -0
- data/ext/imprimatur_native/core/src/doc.rs +210 -0
- data/ext/imprimatur_native/core/src/docx.rs +343 -0
- data/ext/imprimatur_native/core/src/error.rs +113 -0
- data/ext/imprimatur_native/core/src/lib.rs +38 -0
- data/ext/imprimatur_native/core/src/opc.rs +51 -0
- data/ext/imprimatur_native/core/src/pdf/font.rs +96 -0
- data/ext/imprimatur_native/core/src/pdf/layout/wrap.rs +161 -0
- data/ext/imprimatur_native/core/src/pdf/layout.rs +280 -0
- data/ext/imprimatur_native/core/src/pdf/metrics.rs +139 -0
- data/ext/imprimatur_native/core/src/pdf/objects.rs +141 -0
- data/ext/imprimatur_native/core/src/pdf.rs +84 -0
- data/ext/imprimatur_native/core/src/read/budget.rs +140 -0
- data/ext/imprimatur_native/core/src/read/content.rs +266 -0
- data/ext/imprimatur_native/core/src/read/docx/styles.rs +118 -0
- data/ext/imprimatur_native/core/src/read/docx.rs +336 -0
- data/ext/imprimatur_native/core/src/read/image/dimensions.rs +215 -0
- data/ext/imprimatur_native/core/src/read/image.rs +233 -0
- data/ext/imprimatur_native/core/src/read/inflate.rs +44 -0
- data/ext/imprimatur_native/core/src/read/json.rs +172 -0
- data/ext/imprimatur_native/core/src/read/markdown.rs +254 -0
- data/ext/imprimatur_native/core/src/read/package.rs +191 -0
- data/ext/imprimatur_native/core/src/read/pdf/cmap.rs +216 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/geometry.rs +79 -0
- data/ext/imprimatur_native/core/src/read/pdf/content/operands.rs +156 -0
- data/ext/imprimatur_native/core/src/read/pdf/content.rs +439 -0
- data/ext/imprimatur_native/core/src/read/pdf/crypt.rs +256 -0
- data/ext/imprimatur_native/core/src/read/pdf/document.rs +420 -0
- data/ext/imprimatur_native/core/src/read/pdf/filter.rs +258 -0
- data/ext/imprimatur_native/core/src/read/pdf/font.rs +315 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.rs +179 -0
- data/ext/imprimatur_native/core/src/read/pdf/glyphs.txt +6298 -0
- data/ext/imprimatur_native/core/src/read/pdf/images.rs +365 -0
- data/ext/imprimatur_native/core/src/read/pdf/layout.rs +451 -0
- data/ext/imprimatur_native/core/src/read/pdf/lexer.rs +255 -0
- data/ext/imprimatur_native/core/src/read/pdf/object/name.rs +44 -0
- data/ext/imprimatur_native/core/src/read/pdf/object.rs +261 -0
- data/ext/imprimatur_native/core/src/read/pdf/xref.rs +346 -0
- data/ext/imprimatur_native/core/src/read/pdf.rs +53 -0
- data/ext/imprimatur_native/core/src/read/span.rs +22 -0
- data/ext/imprimatur_native/core/src/read/xlsx/numbers.rs +155 -0
- data/ext/imprimatur_native/core/src/read/xlsx.rs +340 -0
- data/ext/imprimatur_native/core/src/read.rs +145 -0
- data/ext/imprimatur_native/core/src/style.rs +82 -0
- data/ext/imprimatur_native/core/src/test_support.rs +163 -0
- data/ext/imprimatur_native/core/src/xlsx/cell.rs +112 -0
- data/ext/imprimatur_native/core/src/xlsx/comment.rs +256 -0
- data/ext/imprimatur_native/core/src/xlsx/date.rs +173 -0
- data/ext/imprimatur_native/core/src/xlsx/format.rs +226 -0
- data/ext/imprimatur_native/core/src/xlsx/package.rs +268 -0
- data/ext/imprimatur_native/core/src/xlsx/range.rs +124 -0
- data/ext/imprimatur_native/core/src/xlsx/styles.rs +240 -0
- data/ext/imprimatur_native/core/src/xlsx/text.rs +122 -0
- data/ext/imprimatur_native/core/src/xlsx/validation.rs +258 -0
- data/ext/imprimatur_native/core/src/xlsx/worksheet.rs +350 -0
- data/ext/imprimatur_native/core/src/xlsx.rs +120 -0
- data/ext/imprimatur_native/core/src/xml/reader.rs +238 -0
- data/ext/imprimatur_native/core/src/xml.rs +95 -0
- data/ext/imprimatur_native/core/src/zip/reader.rs +144 -0
- data/ext/imprimatur_native/core/src/zip.rs +161 -0
- data/ext/imprimatur_native/extconf.rb +45 -0
- data/ext/imprimatur_native/ffi/Cargo.toml +21 -0
- data/ext/imprimatur_native/ffi/src/convert.rs +132 -0
- data/ext/imprimatur_native/ffi/src/document.rs +138 -0
- data/ext/imprimatur_native/ffi/src/lib.rs +301 -0
- data/ext/imprimatur_native/ffi/src/read.rs +182 -0
- data/ext/imprimatur_native/ffi/src/style.rs +85 -0
- data/ext/imprimatur_native/ffi/src/validation.rs +56 -0
- data/lib/imprimatur/document.rb +127 -0
- data/lib/imprimatur/error.rb +22 -0
- data/lib/imprimatur/formula.rb +13 -0
- data/lib/imprimatur/native.rb +17 -0
- data/lib/imprimatur/reader.rb +59 -0
- data/lib/imprimatur/style.rb +31 -0
- data/lib/imprimatur/validation.rb +35 -0
- data/lib/imprimatur/version.rb +5 -0
- data/lib/imprimatur/xlsx.rb +183 -0
- data/lib/imprimatur.rb +111 -0
- metadata +157 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//! `SpreadsheetML` (`.xlsx`) workbooks, per ECMA-376 Part 1.
|
|
2
|
+
|
|
3
|
+
mod cell;
|
|
4
|
+
mod comment;
|
|
5
|
+
mod date;
|
|
6
|
+
mod format;
|
|
7
|
+
mod package;
|
|
8
|
+
mod range;
|
|
9
|
+
mod styles;
|
|
10
|
+
#[cfg(test)]
|
|
11
|
+
mod tests;
|
|
12
|
+
pub(crate) mod text;
|
|
13
|
+
mod validation;
|
|
14
|
+
mod worksheet;
|
|
15
|
+
|
|
16
|
+
use std::fs::File;
|
|
17
|
+
use std::io::{BufWriter, Write};
|
|
18
|
+
use std::path::Path;
|
|
19
|
+
|
|
20
|
+
pub use crate::style::{Align, Color, TextDirection};
|
|
21
|
+
pub(crate) use cell::push_cell_reference;
|
|
22
|
+
pub use cell::{Cell, cell_reference, column_name};
|
|
23
|
+
pub use comment::Comment;
|
|
24
|
+
pub use date::DateTime;
|
|
25
|
+
pub use format::{Border, Format, VerticalAlign};
|
|
26
|
+
pub use range::{CellRange, parse_cell_reference};
|
|
27
|
+
pub use validation::{Alert, Validation};
|
|
28
|
+
pub use worksheet::{MAX_COLUMNS, MAX_ROWS, Worksheet};
|
|
29
|
+
|
|
30
|
+
use crate::{Error, Result};
|
|
31
|
+
|
|
32
|
+
/// The longest sheet name Excel accepts.
|
|
33
|
+
const MAX_SHEET_NAME: usize = 31;
|
|
34
|
+
|
|
35
|
+
/// A workbook: an ordered list of uniquely named worksheets.
|
|
36
|
+
#[derive(Debug, Default)]
|
|
37
|
+
pub struct Workbook {
|
|
38
|
+
sheets: Vec<Worksheet>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
impl Workbook {
|
|
42
|
+
#[must_use]
|
|
43
|
+
pub const fn new() -> Self {
|
|
44
|
+
Self { sheets: Vec::new() }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Adds a sheet after the existing ones and returns it for writing.
|
|
48
|
+
///
|
|
49
|
+
/// # Errors
|
|
50
|
+
/// [`Error::InvalidSheetName`] for a name Excel rejects, and
|
|
51
|
+
/// [`Error::DuplicateSheetName`] when the name is taken, ignoring case.
|
|
52
|
+
pub fn add_sheet(&mut self, name: impl Into<String>) -> Result<&mut Worksheet> {
|
|
53
|
+
let name = name.into();
|
|
54
|
+
validate_sheet_name(&name)?;
|
|
55
|
+
let folded = name.to_lowercase();
|
|
56
|
+
if self.sheets.iter().any(|sheet| sheet.name().to_lowercase() == folded) {
|
|
57
|
+
return Err(Error::DuplicateSheetName(name));
|
|
58
|
+
}
|
|
59
|
+
Ok(self.sheets.push_mut(Worksheet::new(name)))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#[must_use]
|
|
63
|
+
pub fn sheets(&self) -> &[Worksheet] {
|
|
64
|
+
&self.sheets
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub fn sheet_mut(&mut self, index: usize) -> Option<&mut Worksheet> {
|
|
68
|
+
self.sheets.get_mut(index)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Writes the `.xlsx` package to `out` and returns it.
|
|
72
|
+
///
|
|
73
|
+
/// # Errors
|
|
74
|
+
/// [`Error::EmptyWorkbook`] without sheets, [`Error::Io`] when `out` fails.
|
|
75
|
+
pub fn write_to<W: Write>(&self, out: W) -> Result<W> {
|
|
76
|
+
package::write(&self.sheets, out)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// The `.xlsx` package as bytes.
|
|
80
|
+
///
|
|
81
|
+
/// # Errors
|
|
82
|
+
/// As [`write_to`](Self::write_to).
|
|
83
|
+
pub fn to_bytes(&self) -> Result<Vec<u8>> {
|
|
84
|
+
self.write_to(Vec::new())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Writes the `.xlsx` package to a file, replacing it if it exists.
|
|
88
|
+
///
|
|
89
|
+
/// # Errors
|
|
90
|
+
/// As [`write_to`](Self::write_to).
|
|
91
|
+
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
|
|
92
|
+
let file = File::create(path)?;
|
|
93
|
+
self.write_to(BufWriter::new(file))?;
|
|
94
|
+
Ok(())
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fn validate_sheet_name(name: &str) -> Result<()> {
|
|
99
|
+
let invalid = |reason| Err(Error::InvalidSheetName { name: name.to_owned(), reason });
|
|
100
|
+
if name.is_empty() {
|
|
101
|
+
return invalid("it is empty");
|
|
102
|
+
}
|
|
103
|
+
// Excel counts UTF-16 code units.
|
|
104
|
+
if name.encode_utf16().count() > MAX_SHEET_NAME {
|
|
105
|
+
return invalid("it is longer than 31 characters");
|
|
106
|
+
}
|
|
107
|
+
if name.contains(['[', ']', ':', '*', '?', '/', '\\']) {
|
|
108
|
+
return invalid("it contains one of [ ] : * ? / \\");
|
|
109
|
+
}
|
|
110
|
+
if name.chars().any(crate::xml::is_forbidden) {
|
|
111
|
+
return invalid("it contains a control character");
|
|
112
|
+
}
|
|
113
|
+
if name.starts_with('\'') || name.ends_with('\'') {
|
|
114
|
+
return invalid("it starts or ends with an apostrophe");
|
|
115
|
+
}
|
|
116
|
+
if name.eq_ignore_ascii_case("history") {
|
|
117
|
+
return invalid("Excel reserves the name History");
|
|
118
|
+
}
|
|
119
|
+
Ok(())
|
|
120
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
//! A pull parser for the XML parts of OOXML packages. It reads untrusted
|
|
2
|
+
//! input, so it is deliberately small: no DTDs (refused outright, which rules
|
|
3
|
+
//! out entity expansion attacks), only the five predefined entities and
|
|
4
|
+
//! character references, and a nesting limit. Names, attributes and text
|
|
5
|
+
//! borrow from the input; only text with an entity in it is copied.
|
|
6
|
+
//!
|
|
7
|
+
//! Element and attribute names are matched by their local part: `w:p` is
|
|
8
|
+
//! `p`. OOXML parts use one namespace per prefix in practice.
|
|
9
|
+
|
|
10
|
+
#[cfg(test)]
|
|
11
|
+
mod tests;
|
|
12
|
+
|
|
13
|
+
use std::borrow::Cow;
|
|
14
|
+
|
|
15
|
+
use crate::{Error, Result};
|
|
16
|
+
|
|
17
|
+
/// One step through the document. A self-closing element is a `Start`
|
|
18
|
+
/// followed by its `End`, so readers need not tell the two forms apart.
|
|
19
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
20
|
+
pub enum Event<'a> {
|
|
21
|
+
Start(Tag<'a>),
|
|
22
|
+
End(&'a str),
|
|
23
|
+
Text(Cow<'a, str>),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// An opening tag: its local name and its raw, still escaped, attributes.
|
|
27
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
28
|
+
pub struct Tag<'a> {
|
|
29
|
+
name: &'a str,
|
|
30
|
+
attrs: &'a str,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
impl<'a> Tag<'a> {
|
|
34
|
+
/// A tag of this name with these raw attributes, as they sit between
|
|
35
|
+
/// the name and the closing `>`.
|
|
36
|
+
pub const fn new(name: &'a str, attrs: &'a str) -> Self {
|
|
37
|
+
Self { name, attrs }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
pub const fn name(&self) -> &'a str {
|
|
41
|
+
self.name
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// The attribute with this local name, unescaped.
|
|
45
|
+
pub fn attr(&self, local: &str) -> Option<Cow<'a, str>> {
|
|
46
|
+
let mut rest = self.attrs;
|
|
47
|
+
loop {
|
|
48
|
+
rest = rest.trim_start();
|
|
49
|
+
let (name, after) = rest.split_once('=')?;
|
|
50
|
+
let after = after.trim_start();
|
|
51
|
+
let quote = after.chars().next().filter(|"e| quote == '"' || quote == '\'')?;
|
|
52
|
+
let (value, tail) = after[1..].split_once(quote)?;
|
|
53
|
+
if local_name(name.trim_end()) == local {
|
|
54
|
+
return unescape(value).ok();
|
|
55
|
+
}
|
|
56
|
+
rest = tail;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// `w:val`-style booleans: present without a value, `1`, `true` or
|
|
61
|
+
/// `on` are true; Word writes `w:val="0"` to switch a property off.
|
|
62
|
+
pub fn flag(&self, local: &str) -> bool {
|
|
63
|
+
self.attr(local).is_none_or(|value| matches!(&*value, "1" | "true" | "on"))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pub struct XmlReader<'a> {
|
|
68
|
+
text: &'a str,
|
|
69
|
+
pos: usize,
|
|
70
|
+
depth: usize,
|
|
71
|
+
max_depth: usize,
|
|
72
|
+
/// The `End` a self-closing element still owes.
|
|
73
|
+
pending_end: Option<&'a str>,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
impl<'a> XmlReader<'a> {
|
|
77
|
+
pub const fn new(text: &'a str, max_depth: usize) -> Self {
|
|
78
|
+
Self { text, pos: 0, depth: 0, max_depth, pending_end: None }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// The next event, or `None` at the end of the input.
|
|
82
|
+
///
|
|
83
|
+
/// # Errors
|
|
84
|
+
/// [`Error::Malformed`] for broken markup or a DTD, and
|
|
85
|
+
/// [`Error::LimitExceeded`] beyond the nesting limit.
|
|
86
|
+
pub fn next(&mut self) -> Result<Option<Event<'a>>> {
|
|
87
|
+
if let Some(name) = self.pending_end.take() {
|
|
88
|
+
self.depth -= 1;
|
|
89
|
+
return Ok(Some(Event::End(name)));
|
|
90
|
+
}
|
|
91
|
+
loop {
|
|
92
|
+
let rest = &self.text[self.pos..];
|
|
93
|
+
if rest.is_empty() {
|
|
94
|
+
return Ok(None);
|
|
95
|
+
}
|
|
96
|
+
if !rest.starts_with('<') {
|
|
97
|
+
let end = rest.find('<').unwrap_or(rest.len());
|
|
98
|
+
self.pos += end;
|
|
99
|
+
return Ok(Some(Event::Text(unescape(&rest[..end])?)));
|
|
100
|
+
}
|
|
101
|
+
if let Some(body) = rest.strip_prefix("<?") {
|
|
102
|
+
self.pos += 2 + find(body, "?>")? + 2;
|
|
103
|
+
} else if let Some(body) = rest.strip_prefix("<!--") {
|
|
104
|
+
self.pos += 4 + find(body, "-->")? + 3;
|
|
105
|
+
} else if let Some(body) = rest.strip_prefix("<![CDATA[") {
|
|
106
|
+
let end = find(body, "]]>")?;
|
|
107
|
+
self.pos += 9 + end + 3;
|
|
108
|
+
return Ok(Some(Event::Text(Cow::Borrowed(&body[..end]))));
|
|
109
|
+
} else if rest.starts_with("<!") {
|
|
110
|
+
return Err(malformed("a DTD or declaration, which is not allowed"));
|
|
111
|
+
} else if let Some(body) = rest.strip_prefix("</") {
|
|
112
|
+
let end = find(body, ">")?;
|
|
113
|
+
self.pos += 2 + end + 1;
|
|
114
|
+
self.depth =
|
|
115
|
+
self.depth.checked_sub(1).ok_or_else(|| malformed("an extra end tag"))?;
|
|
116
|
+
return Ok(Some(Event::End(local_name(body[..end].trim_end()))));
|
|
117
|
+
} else {
|
|
118
|
+
return self.start(&rest[1..]).map(Some);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Skips to the end of the element whose `Start` was just read.
|
|
124
|
+
pub fn skip(&mut self) -> Result<()> {
|
|
125
|
+
let mut open = 1_usize;
|
|
126
|
+
while open > 0 {
|
|
127
|
+
match self.next()? {
|
|
128
|
+
Some(Event::Start(_)) => open += 1,
|
|
129
|
+
Some(Event::End(_)) => open -= 1,
|
|
130
|
+
Some(Event::Text(_)) => {}
|
|
131
|
+
None => return Err(malformed("an element that never ends")),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
Ok(())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn start(&mut self, body: &'a str) -> Result<Event<'a>> {
|
|
138
|
+
let end = tag_end(body)?;
|
|
139
|
+
let (inner, empty) =
|
|
140
|
+
body[..end].strip_suffix('/').map_or((&body[..end], false), |inner| (inner, true));
|
|
141
|
+
let name_end = inner.find(|ch: char| ch.is_ascii_whitespace()).unwrap_or(inner.len());
|
|
142
|
+
let name = &inner[..name_end];
|
|
143
|
+
if name.is_empty() {
|
|
144
|
+
return Err(malformed("a tag without a name"));
|
|
145
|
+
}
|
|
146
|
+
self.pos += 1 + end + 1;
|
|
147
|
+
self.depth += 1;
|
|
148
|
+
if self.depth > self.max_depth {
|
|
149
|
+
return Err(Error::LimitExceeded("XML nesting depth"));
|
|
150
|
+
}
|
|
151
|
+
let name = local_name(name);
|
|
152
|
+
if empty {
|
|
153
|
+
self.pending_end = Some(name);
|
|
154
|
+
}
|
|
155
|
+
Ok(Event::Start(Tag { name, attrs: &inner[name_end..] }))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// A part's text: UTF-8, with or without a byte order mark, or UTF-16
|
|
160
|
+
/// with one. UTF-8 takes over the bytes' allocation.
|
|
161
|
+
pub fn into_text(mut bytes: Vec<u8>) -> Result<String> {
|
|
162
|
+
let utf16 = |bytes: &[u8], from: fn([u8; 2]) -> u16| {
|
|
163
|
+
let units = bytes.as_chunks::<2>().0.iter().map(|pair| from([pair[0], pair[1]]));
|
|
164
|
+
char::decode_utf16(units)
|
|
165
|
+
.collect::<Result<String, _>>()
|
|
166
|
+
.map_err(|_| malformed("invalid UTF-16"))
|
|
167
|
+
};
|
|
168
|
+
match bytes.as_slice() {
|
|
169
|
+
[0xFF, 0xFE, rest @ ..] => return utf16(rest, u16::from_le_bytes),
|
|
170
|
+
[0xFE, 0xFF, rest @ ..] => return utf16(rest, u16::from_be_bytes),
|
|
171
|
+
[0xEF, 0xBB, 0xBF, ..] => {
|
|
172
|
+
bytes.drain(..3);
|
|
173
|
+
}
|
|
174
|
+
_ => {}
|
|
175
|
+
}
|
|
176
|
+
String::from_utf8(bytes).map_err(|_| malformed("invalid UTF-8"))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Replaces the predefined entities and character references.
|
|
180
|
+
pub fn unescape(text: &str) -> Result<Cow<'_, str>> {
|
|
181
|
+
let Some(first) = text.find('&') else {
|
|
182
|
+
return Ok(Cow::Borrowed(text));
|
|
183
|
+
};
|
|
184
|
+
let mut out = String::with_capacity(text.len());
|
|
185
|
+
out.push_str(&text[..first]);
|
|
186
|
+
let mut rest = &text[first..];
|
|
187
|
+
while let Some(at) = rest.find('&') {
|
|
188
|
+
out.push_str(&rest[..at]);
|
|
189
|
+
let entity = &rest[at + 1..];
|
|
190
|
+
let end = entity.find(';').filter(|&end| end <= 10).ok_or_else(|| malformed("a bare &"))?;
|
|
191
|
+
out.push(match &entity[..end] {
|
|
192
|
+
"lt" => '<',
|
|
193
|
+
"gt" => '>',
|
|
194
|
+
"amp" => '&',
|
|
195
|
+
"quot" => '"',
|
|
196
|
+
"apos" => '\'',
|
|
197
|
+
reference => character_reference(reference)?,
|
|
198
|
+
});
|
|
199
|
+
rest = &entity[end + 1..];
|
|
200
|
+
}
|
|
201
|
+
out.push_str(rest);
|
|
202
|
+
Ok(Cow::Owned(out))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn character_reference(reference: &str) -> Result<char> {
|
|
206
|
+
let code = match reference.strip_prefix('#') {
|
|
207
|
+
Some(hex) if hex.starts_with(['x', 'X']) => u32::from_str_radix(&hex[1..], 16).ok(),
|
|
208
|
+
Some(decimal) => decimal.parse().ok(),
|
|
209
|
+
None => None,
|
|
210
|
+
};
|
|
211
|
+
code.and_then(char::from_u32).ok_or_else(|| malformed("an unknown entity"))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// The end of a start tag: its `>`, outside quoted attribute values.
|
|
215
|
+
fn tag_end(body: &str) -> Result<usize> {
|
|
216
|
+
let mut quote = None;
|
|
217
|
+
for (at, byte) in body.bytes().enumerate() {
|
|
218
|
+
match (quote, byte) {
|
|
219
|
+
(None, b'>') => return Ok(at),
|
|
220
|
+
(None, b'"' | b'\'') => quote = Some(byte),
|
|
221
|
+
(Some(open), _) if open == byte => quote = None,
|
|
222
|
+
_ => {}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
Err(malformed("an unterminated tag"))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn find(haystack: &str, needle: &str) -> Result<usize> {
|
|
229
|
+
haystack.find(needle).ok_or_else(|| malformed("unterminated markup"))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
pub fn local_name(name: &str) -> &str {
|
|
233
|
+
name.rsplit_once(':').map_or(name, |(_, local)| local)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
fn malformed(reason: &str) -> Error {
|
|
237
|
+
Error::malformed("XML", reason)
|
|
238
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//! The XML emitter every written part goes through: all it has to get right
|
|
2
|
+
//! is escaping and well-formedness. [`reader`] parses the parts of the
|
|
3
|
+
//! documents being read.
|
|
4
|
+
|
|
5
|
+
pub mod reader;
|
|
6
|
+
#[cfg(test)]
|
|
7
|
+
mod tests;
|
|
8
|
+
|
|
9
|
+
/// Builds one XML part in memory.
|
|
10
|
+
pub struct XmlWriter {
|
|
11
|
+
buf: String,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl XmlWriter {
|
|
15
|
+
/// Starts a part with the standalone XML declaration Office writes.
|
|
16
|
+
pub fn new() -> Self {
|
|
17
|
+
Self {
|
|
18
|
+
buf: String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n"),
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Starts a part without the declaration, as legacy VML is written.
|
|
23
|
+
pub const fn bare() -> Self {
|
|
24
|
+
Self { buf: String::new() }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Writes `<name a="v" ...>`. Attribute values are escaped.
|
|
28
|
+
pub fn open(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
|
29
|
+
self.tag(name, attrs);
|
|
30
|
+
self.buf.push('>');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Writes `<name a="v" .../>`.
|
|
34
|
+
pub fn empty(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
|
35
|
+
self.tag(name, attrs);
|
|
36
|
+
self.buf.push_str("/>");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pub fn close(&mut self, name: &str) {
|
|
40
|
+
self.buf.push_str("</");
|
|
41
|
+
self.buf.push_str(name);
|
|
42
|
+
self.buf.push('>');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Writes escaped character data.
|
|
46
|
+
pub fn text(&mut self, text: &str) {
|
|
47
|
+
escape_into(&mut self.buf, text, false);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// `<name attrs>text</name>`.
|
|
51
|
+
pub fn element(&mut self, name: &str, attrs: &[(&str, &str)], text: &str) {
|
|
52
|
+
self.open(name, attrs);
|
|
53
|
+
self.text(text);
|
|
54
|
+
self.close(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub fn finish(self) -> String {
|
|
58
|
+
self.buf
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fn tag(&mut self, name: &str, attrs: &[(&str, &str)]) {
|
|
62
|
+
self.buf.push('<');
|
|
63
|
+
self.buf.push_str(name);
|
|
64
|
+
for (key, value) in attrs {
|
|
65
|
+
self.buf.push(' ');
|
|
66
|
+
self.buf.push_str(key);
|
|
67
|
+
self.buf.push_str("=\"");
|
|
68
|
+
escape_into(&mut self.buf, value, true);
|
|
69
|
+
self.buf.push('"');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Escapes markup characters. In attributes, whitespace other than a space is
|
|
75
|
+
/// written as a character reference too, or attribute normalization would turn
|
|
76
|
+
/// it into a space on read.
|
|
77
|
+
fn escape_into(buf: &mut String, text: &str, attribute: bool) {
|
|
78
|
+
for ch in text.chars() {
|
|
79
|
+
match ch {
|
|
80
|
+
'&' => buf.push_str("&"),
|
|
81
|
+
'<' => buf.push_str("<"),
|
|
82
|
+
'>' => buf.push_str(">"),
|
|
83
|
+
'"' if attribute => buf.push_str("""),
|
|
84
|
+
'\t' if attribute => buf.push_str("	"),
|
|
85
|
+
'\n' if attribute => buf.push_str(" "),
|
|
86
|
+
'\r' => buf.push_str(" "),
|
|
87
|
+
ch => buf.push(ch),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// True for characters XML 1.0 cannot carry at all, not even escaped.
|
|
93
|
+
pub const fn is_forbidden(ch: char) -> bool {
|
|
94
|
+
matches!(ch, '\0'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}')
|
|
95
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
//! Reads the zip container of an OOXML package. Entries are inflated on
|
|
2
|
+
//! demand, each against the read's decompression budget and checked against
|
|
3
|
+
//! its CRC, so a zip bomb fails before it fills memory.
|
|
4
|
+
|
|
5
|
+
#[cfg(test)]
|
|
6
|
+
mod tests;
|
|
7
|
+
|
|
8
|
+
use super::{
|
|
9
|
+
CENTRAL_HEADER, END_OF_CENTRAL_DIRECTORY, LOCAL_HEADER, METHOD_DEFLATED, METHOD_STORED,
|
|
10
|
+
};
|
|
11
|
+
use crate::read::Budget;
|
|
12
|
+
use crate::{Error, Result};
|
|
13
|
+
|
|
14
|
+
/// The end of central directory record without its trailing comment.
|
|
15
|
+
const END_RECORD: usize = 22;
|
|
16
|
+
const MAX_COMMENT: usize = u16::MAX as usize;
|
|
17
|
+
|
|
18
|
+
struct Entry<'a> {
|
|
19
|
+
name: &'a str,
|
|
20
|
+
method: u16,
|
|
21
|
+
crc: u32,
|
|
22
|
+
compressed: usize,
|
|
23
|
+
size: usize,
|
|
24
|
+
offset: usize,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub struct ZipReader<'a> {
|
|
28
|
+
data: &'a [u8],
|
|
29
|
+
entries: Vec<Entry<'a>>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl<'a> ZipReader<'a> {
|
|
33
|
+
/// Reads the central directory.
|
|
34
|
+
///
|
|
35
|
+
/// # Errors
|
|
36
|
+
/// [`Error::Malformed`] when `data` is not a zip archive this reader can
|
|
37
|
+
/// handle (zip64 and encrypted entries are not).
|
|
38
|
+
pub fn new(data: &'a [u8]) -> Result<Self> {
|
|
39
|
+
let search = data.len().saturating_sub(END_RECORD + MAX_COMMENT);
|
|
40
|
+
let end = (search..=data.len().saturating_sub(END_RECORD))
|
|
41
|
+
.rev()
|
|
42
|
+
.find(|&at| u32_at(data, at) == Some(END_OF_CENTRAL_DIRECTORY))
|
|
43
|
+
.ok_or_else(|| malformed("no end of central directory"))?;
|
|
44
|
+
let count = usize::from(u16_at(data, end + 10).ok_or_else(|| malformed("truncated"))?);
|
|
45
|
+
let mut at = u32_at(data, end + 16).ok_or_else(|| malformed("truncated"))? as usize;
|
|
46
|
+
if at == u32::MAX as usize {
|
|
47
|
+
return Err(Error::UnsupportedDocument("zip64 archives".to_owned()));
|
|
48
|
+
}
|
|
49
|
+
let mut entries = Vec::with_capacity(count);
|
|
50
|
+
for _ in 0..count {
|
|
51
|
+
(u32_at(data, at) == Some(CENTRAL_HEADER))
|
|
52
|
+
.ok_or_else(|| malformed("bad central directory"))?;
|
|
53
|
+
let field = |offset: usize| u16_at(data, at + offset).map(usize::from);
|
|
54
|
+
let word = |offset: usize| u32_at(data, at + offset);
|
|
55
|
+
let (Some(flags), Some(method), Some(crc), Some(compressed), Some(size)) =
|
|
56
|
+
(field(8), field(10), word(16), word(20), word(24))
|
|
57
|
+
else {
|
|
58
|
+
return Err(malformed("truncated central directory"));
|
|
59
|
+
};
|
|
60
|
+
let (Some(name_len), Some(extra_len), Some(comment_len), Some(offset)) =
|
|
61
|
+
(field(28), field(30), field(32), word(42))
|
|
62
|
+
else {
|
|
63
|
+
return Err(malformed("truncated central directory"));
|
|
64
|
+
};
|
|
65
|
+
if flags & 1 != 0 {
|
|
66
|
+
return Err(Error::UnsupportedDocument("encrypted zip entries".to_owned()));
|
|
67
|
+
}
|
|
68
|
+
let name = data
|
|
69
|
+
.get(at + 46..at + 46 + name_len)
|
|
70
|
+
.and_then(|name| std::str::from_utf8(name).ok())
|
|
71
|
+
.ok_or_else(|| malformed("bad entry name"))?;
|
|
72
|
+
entries.push(Entry {
|
|
73
|
+
name: name.trim_start_matches('/'),
|
|
74
|
+
method: u16::try_from(method).unwrap_or(u16::MAX),
|
|
75
|
+
crc,
|
|
76
|
+
compressed: compressed as usize,
|
|
77
|
+
size: size as usize,
|
|
78
|
+
offset: offset as usize,
|
|
79
|
+
});
|
|
80
|
+
at += 46 + name_len + extra_len + comment_len;
|
|
81
|
+
}
|
|
82
|
+
Ok(Self { data, entries })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// The entries' names, in the archive's order.
|
|
86
|
+
pub fn names(&self) -> impl Iterator<Item = &'a str> + '_ {
|
|
87
|
+
self.entries.iter().map(|entry| entry.name)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// The entry named `name` (OPC part names ignore ASCII case), inflated,
|
|
91
|
+
/// or `None` when the archive has none.
|
|
92
|
+
///
|
|
93
|
+
/// # Errors
|
|
94
|
+
/// [`Error::LimitExceeded`] past the decompression budget, and
|
|
95
|
+
/// [`Error::Malformed`] for a corrupt entry.
|
|
96
|
+
pub fn read(&self, name: &str, budget: &Budget<'_>) -> Result<Option<Vec<u8>>> {
|
|
97
|
+
let name = name.trim_start_matches('/');
|
|
98
|
+
let Some(entry) = self.entries.iter().find(|entry| entry.name.eq_ignore_ascii_case(name))
|
|
99
|
+
else {
|
|
100
|
+
return Ok(None);
|
|
101
|
+
};
|
|
102
|
+
let header = entry.offset;
|
|
103
|
+
(u32_at(self.data, header) == Some(LOCAL_HEADER))
|
|
104
|
+
.ok_or_else(|| malformed("bad local header"))?;
|
|
105
|
+
let (Some(name_len), Some(extra_len)) =
|
|
106
|
+
(u16_at(self.data, header + 26), u16_at(self.data, header + 28))
|
|
107
|
+
else {
|
|
108
|
+
return Err(malformed("truncated local header"));
|
|
109
|
+
};
|
|
110
|
+
let start = header + 30 + usize::from(name_len) + usize::from(extra_len);
|
|
111
|
+
let stored = self
|
|
112
|
+
.data
|
|
113
|
+
.get(start..start + entry.compressed)
|
|
114
|
+
.ok_or_else(|| malformed("entry data past the end"))?;
|
|
115
|
+
(entry.size <= budget.inflatable()).ok_or(Error::LimitExceeded("decompressed size"))?;
|
|
116
|
+
let data = match entry.method {
|
|
117
|
+
METHOD_STORED => stored.to_vec(),
|
|
118
|
+
METHOD_DEFLATED => {
|
|
119
|
+
match crate::read::inflate::inflate(stored, false, entry.size, entry.size) {
|
|
120
|
+
Ok((data, true)) => data,
|
|
121
|
+
Ok((_, false)) | Err(_) => return Err(malformed("an entry does not inflate")),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
_ => return Err(Error::UnsupportedDocument("a zip compression method".to_owned())),
|
|
125
|
+
};
|
|
126
|
+
budget.inflate(data.len())?;
|
|
127
|
+
budget.check()?;
|
|
128
|
+
(data.len() == entry.size && crc32fast::hash(&data) == entry.crc)
|
|
129
|
+
.ok_or_else(|| malformed("an entry fails its CRC"))?;
|
|
130
|
+
Ok(Some(data))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fn u16_at(data: &[u8], at: usize) -> Option<u16> {
|
|
135
|
+
Some(u16::from_le_bytes(data.get(at..at + 2)?.try_into().ok()?))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn u32_at(data: &[u8], at: usize) -> Option<u32> {
|
|
139
|
+
Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn malformed(reason: &str) -> Error {
|
|
143
|
+
Error::malformed("zip", reason)
|
|
144
|
+
}
|