mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 08:53:03 -06:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4251dad939 | ||
|
|
1b7e4f00dc | ||
|
|
d3954beacf | ||
|
|
26e7112e0f | ||
|
|
d0c567992e | ||
|
|
a440cfac28 | ||
|
|
92a79db048 | ||
|
|
652c08a89b | ||
|
|
aeb3fb19ce | ||
|
|
e4ec0d7ed1 | ||
|
|
23487b0cb5 | ||
|
|
c913bf6615 | ||
|
|
0431741359 | ||
|
|
9c87c22662 | ||
|
|
c1adf9b239 |
Generated
+78
-8
@@ -739,6 +739,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "cmpv2"
|
||||
version = "0.2.0"
|
||||
@@ -757,7 +763,7 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"spki",
|
||||
"x509-cert",
|
||||
@@ -827,6 +833,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -912,6 +924,14 @@ name = "craft-cfb"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-csv"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -940,6 +960,23 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-offcrypto"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
"craft-cfb",
|
||||
"craft-xml",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -1058,6 +1095,15 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1138,6 +1184,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -1150,7 +1205,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
@@ -1215,7 +1270,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.3",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1906,9 +1963,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2123,6 +2182,7 @@ dependencies = [
|
||||
name = "gridcraft-engine"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-csv",
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
@@ -2263,7 +2323,9 @@ dependencies = [
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-cfb",
|
||||
"craft-csv",
|
||||
"craft-offcrypto",
|
||||
"craft-xls",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2271,7 +2333,6 @@ dependencies = [
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip 3.0.0",
|
||||
@@ -2376,6 +2437,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -4838,7 +4908,7 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"pem",
|
||||
@@ -4912,7 +4982,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"cmpv2",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"hex",
|
||||
"rand 0.10.3",
|
||||
@@ -4951,7 +5021,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"hex",
|
||||
"pem",
|
||||
"rustls-pki-types",
|
||||
@@ -6676,7 +6746,7 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"sha1 0.10.7",
|
||||
"signature",
|
||||
|
||||
+6
-3
@@ -31,6 +31,12 @@ gridcraft-math = { path = "crates/math" }
|
||||
# craft-libs revision.
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "=0.1.0", features = ["egui"] }
|
||||
# Compound files ([MS-CFB]) and password-encrypted OOXML packages ([MS-OFFCRYPTO]).
|
||||
craft-cfb = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
craft-offcrypto = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Delimited text (CSV, TSV) and SYLK; `crates/xlsx/src/csv.rs` and `crates/engine/src/io/sylk.rs`
|
||||
# turn them into workbooks.
|
||||
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
|
||||
@@ -47,9 +53,6 @@ regex = { version = "1", default-features = false, features = ["std", "unicode",
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
|
||||
aes = { version = "0.9", default-features = false }
|
||||
sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
|
||||
|
||||
@@ -21,6 +21,7 @@ web-time = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
|
||||
+37
-301
@@ -1,156 +1,57 @@
|
||||
//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems
|
||||
//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon):
|
||||
//!
|
||||
//! - `ID` starts the file; `E` ends it.
|
||||
//! - `C;Y<row>;X<col>;K<value>;E<formula>` is a cell. `Y` and `X` may be left out to stay on the
|
||||
//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a
|
||||
//! formula in R1C1 notation; `S;R<row>;C<col>` shares the formula of another cell.
|
||||
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
|
||||
//! n-th format (0-based), and `F;W<first> <last> <width>` sets column widths in characters.
|
||||
//!
|
||||
//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with
|
||||
//! the same relative and absolute references, as Excel does when it opens the file.
|
||||
//! SYLK (`.slk`) import. `craft_csv::sylk` reads the file (values, formulas rewritten from R1C1 to
|
||||
//! A1, number formats, column widths); this module makes it a one-sheet workbook: formulas parsed
|
||||
//! (a formula GridCraft can't parse is skipped and counted), error values recognised, formats
|
||||
//! interned as styles and widths converted from characters.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
|
||||
use craft_csv::sylk;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// The largest SYLK file read, as for other text imports: past it the file is refused.
|
||||
const MAX_BYTES: usize = 512 << 20;
|
||||
/// Column-width records kept from one file; each can cover every column.
|
||||
const MAX_WIDTH_RECORDS: usize = 1024;
|
||||
|
||||
/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings.
|
||||
pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20)));
|
||||
}
|
||||
let text = match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
// Older exports are in a single-byte code page: keep each byte as its Latin-1 character.
|
||||
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
|
||||
};
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
|
||||
if !text.starts_with("ID") {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")));
|
||||
}
|
||||
let file = sylk::read(bytes).map_err(|e| match e {
|
||||
sylk::Error::TooLarge => EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", sylk::MAX_BYTES >> 20)),
|
||||
_ => EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")),
|
||||
})?;
|
||||
let mut wb = Workbook::new();
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
let mut formats: Vec<String> = Vec::new();
|
||||
// Cells' number formats by index into `formats`, applied once every format is known.
|
||||
let mut cell_formats: Vec<(CellRef, usize)> = Vec::new();
|
||||
let mut formulas: HashMap<CellRef, String> = HashMap::new();
|
||||
let mut widths: Vec<(u32, u32, f64)> = Vec::new();
|
||||
let (mut row, mut col) = (0u32, 0u32);
|
||||
let mut skipped = 0usize;
|
||||
for line in text.lines() {
|
||||
let fields = split_fields(line);
|
||||
let Some((kind, rest)) = fields.split_first() else { continue };
|
||||
match kind.as_str() {
|
||||
"C" => {
|
||||
let (mut value, mut formula, mut shared) = (None, None, None::<(Option<u32>, Option<u32>)>);
|
||||
let mut bad = false;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => match position(arg, MAX_ROWS) {
|
||||
Some(r) => row = r,
|
||||
None => bad = true,
|
||||
},
|
||||
"X" => match position(arg, MAX_COLS) {
|
||||
Some(c) => col = c,
|
||||
None => bad = true,
|
||||
},
|
||||
"K" => value = Some(parse_value(arg)),
|
||||
"E" => formula = Some(arg.to_string()),
|
||||
"S" => shared = Some((None, None)),
|
||||
// In a shared-formula cell, R and C name the cell whose formula it shares.
|
||||
"R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)),
|
||||
"C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if bad {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let at = CellRef::new(row, col);
|
||||
if let Some((Some(r), Some(c))) = shared {
|
||||
formula = formulas.get(&CellRef::new(r, c)).cloned();
|
||||
}
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
if let Some(v) = value {
|
||||
cell.value = v;
|
||||
}
|
||||
if let Some(r1c1) = formula {
|
||||
let a1 = r1c1_formula_to_a1(&r1c1, at);
|
||||
let f = Formula::new(&a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
formulas.insert(at, r1c1);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_cell(at, cell);
|
||||
let mut skipped = file.skipped;
|
||||
let mut styles = HashMap::new();
|
||||
for (&(row, col), c) in &file.cells {
|
||||
let value = match &c.value {
|
||||
sylk::Value::Number(n) => Value::Number(*n),
|
||||
sylk::Value::Text(s) => Value::Text(Arc::from(s.as_str())),
|
||||
sylk::Value::Bool(b) => Value::Bool(*b),
|
||||
sylk::Value::Error(s) => CellError::parse(s).map_or_else(|| Value::Text(Arc::from(s.as_str())), Value::Error),
|
||||
_ => Value::Empty,
|
||||
};
|
||||
let mut cell = Cell::value(value);
|
||||
if let Some(a1) = &c.formula {
|
||||
let f = Formula::new(a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
"F" => {
|
||||
let mut format = None;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => row = position(arg, MAX_ROWS).unwrap_or(row),
|
||||
"X" => col = position(arg, MAX_COLS).unwrap_or(col),
|
||||
"P" => format = arg.parse::<usize>().ok(),
|
||||
"W" => {
|
||||
let mut parts = arg.split_whitespace();
|
||||
if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next())
|
||||
&& let (Some(first), Some(last), Ok(width)) =
|
||||
(position(first, MAX_COLS), position(last, MAX_COLS), width.parse::<f64>())
|
||||
&& first <= last
|
||||
&& (0.0..=255.0).contains(&width)
|
||||
// A file of width records can't make the import loop over every column
|
||||
// again and again.
|
||||
&& widths.len() < MAX_WIDTH_RECORDS
|
||||
{
|
||||
widths.push((first, last, width));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(format) = format {
|
||||
cell_formats.push((CellRef::new(row, col), format));
|
||||
}
|
||||
}
|
||||
// Number formats only: `P;F…` and `P;E…` are fonts.
|
||||
"P" => {
|
||||
if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) {
|
||||
formats.push(code.to_string());
|
||||
}
|
||||
}
|
||||
"E" => break,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(code) = &c.format {
|
||||
cell.style = *styles
|
||||
.entry(code.as_str())
|
||||
.or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
}
|
||||
sheet.set_cell(CellRef::new(row, col), cell);
|
||||
}
|
||||
for (first, last, width) in widths {
|
||||
let size = crate::cmd::format::chars_to_points(width) as f32;
|
||||
for c in first..=last {
|
||||
for w in &file.column_widths {
|
||||
let size = crate::cmd::format::chars_to_points(w.width) as f32;
|
||||
for c in w.first..=w.last {
|
||||
sheet.cols.entry(c).or_default().size = Some(size);
|
||||
}
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
let mut styles = HashMap::new();
|
||||
for (at, index) in cell_formats {
|
||||
let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue };
|
||||
let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
cell.style = style;
|
||||
sheet.set_cell(at, cell);
|
||||
}
|
||||
if let Some(first) = wb.sheet_mut(0) {
|
||||
sheet.name = std::mem::take(&mut first.name);
|
||||
*first = sheet;
|
||||
@@ -161,168 +62,3 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
|
||||
fn split_fields(line: &str) -> Vec<String> {
|
||||
let mut fields = vec![String::new()];
|
||||
let mut chars = line.trim_end_matches('\r').chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ';' {
|
||||
if chars.peek() == Some(&';') {
|
||||
chars.next();
|
||||
if let Some(f) = fields.last_mut() {
|
||||
f.push(';');
|
||||
}
|
||||
} else {
|
||||
fields.push(String::new());
|
||||
}
|
||||
} else if let Some(f) = fields.last_mut() {
|
||||
f.push(c);
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// A 1-based row or column number → 0-based, within `max`.
|
||||
fn position(arg: &str, max: u32) -> Option<u32> {
|
||||
arg.trim().parse::<u32>().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1)
|
||||
}
|
||||
|
||||
/// A `K` value: `"text"` (everything between the first and the last quote, as written), a
|
||||
/// boolean, an error or a number.
|
||||
fn parse_value(arg: &str) -> Value {
|
||||
if let Some(text) = arg.strip_prefix('"') {
|
||||
let text = text.strip_suffix('"').unwrap_or(text);
|
||||
return Value::Text(Arc::from(text));
|
||||
}
|
||||
if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") {
|
||||
return Value::Bool(arg.eq_ignore_ascii_case("TRUE"));
|
||||
}
|
||||
if let Some(e) = CellError::parse(arg) {
|
||||
return Value::Error(e);
|
||||
}
|
||||
match arg.trim().parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => Value::Number(n),
|
||||
_ => Value::Text(Arc::from(arg)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or
|
||||
/// nothing (the cell's own row or column).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Part {
|
||||
Absolute(u32),
|
||||
Relative(i64),
|
||||
}
|
||||
|
||||
impl Part {
|
||||
/// The 0-based index and whether it is absolute, or `None` when it falls off the sheet.
|
||||
fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> {
|
||||
match self {
|
||||
Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)),
|
||||
Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and
|
||||
/// the index after it, or `None` when the text isn't a reference part.
|
||||
fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> {
|
||||
match chars.get(i) {
|
||||
Some('[') => {
|
||||
let close = chars[i..].iter().position(|&c| c == ']')? + i;
|
||||
let inner: String = chars[i + 1..close].iter().collect();
|
||||
Some((Part::Relative(inner.parse().ok()?), close + 1))
|
||||
}
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i);
|
||||
let n: String = chars[i..end].iter().collect();
|
||||
Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end))
|
||||
}
|
||||
_ => Some((Part::Relative(0), i)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$')
|
||||
}
|
||||
|
||||
/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`:
|
||||
/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and
|
||||
/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference
|
||||
/// off the sheet becomes `#REF!`.
|
||||
pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String {
|
||||
let chars: Vec<char> = formula.chars().collect();
|
||||
let mut out = String::with_capacity(formula.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c == '"' || c == '\'' {
|
||||
// Copy the quoted run as it is; a doubled quote stays inside it.
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) {
|
||||
j += if chars[j] == c { 2 } else { 1 };
|
||||
}
|
||||
let stop = (j + 1).min(chars.len());
|
||||
out.extend(&chars[i..stop]);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
let starts_word = i == 0 || !is_word_char(chars[i - 1]);
|
||||
if starts_word
|
||||
&& matches!(c, 'R' | 'r' | 'C' | 'c')
|
||||
&& let Some((text, next)) = reference_at(&chars, i, at)
|
||||
{
|
||||
out.push_str(&text);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
// Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference.
|
||||
if is_word_char(c) {
|
||||
let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len());
|
||||
out.extend(&chars[i..end]);
|
||||
i = end;
|
||||
} else {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the
|
||||
/// word there isn't a reference (a function or a name).
|
||||
fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> {
|
||||
let mut j = i;
|
||||
let mut row = None;
|
||||
let mut col = None;
|
||||
if matches!(chars.get(j), Some('R' | 'r')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
row = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if matches!(chars.get(j), Some('C' | 'c')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
col = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if row.is_none() && col.is_none() {
|
||||
return None;
|
||||
}
|
||||
// A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names.
|
||||
if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') {
|
||||
return None;
|
||||
}
|
||||
let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1));
|
||||
let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c)));
|
||||
// One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it.
|
||||
let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace());
|
||||
let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':');
|
||||
let text = match (row, col) {
|
||||
(Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")),
|
||||
(Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }),
|
||||
(None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }),
|
||||
(None, None) => None,
|
||||
};
|
||||
Some((text.unwrap_or_else(|| "#REF!".into()), j))
|
||||
}
|
||||
|
||||
@@ -763,7 +763,7 @@ fn sylk_r1c1_formulas_become_a1() {
|
||||
("R[-5]C", "#REF!"),
|
||||
("\"RC\"&R1C1", "\"RC\"&$A$1"),
|
||||
] {
|
||||
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
|
||||
assert_eq!(craft_csv::sylk::r1c1_formula_to_a1(r1c1, (b2.row, b2.col)), a1, "{r1c1}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,13 @@ gridcraft-core = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
gridcraft-model = { workspace = true }
|
||||
craft-cfb = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
craft-offcrypto = { workspace = true }
|
||||
craft-xls = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
//! Just enough of the Compound File Binary (CFB/OLE2) container to tell what a non-zip
|
||||
//! spreadsheet file is: Excel wraps password-protected workbooks in a CFB holding an
|
||||
//! `EncryptedPackage` stream, while Excel 97-2003 `.xls` (and other legacy Office files) are
|
||||
//! CFB files without one. Every read is bounds-checked and every loop bounded.
|
||||
|
||||
/// The CFB signature at the start of the file.
|
||||
pub(crate) const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
|
||||
/// Sector ids at or above this are markers (free, end of chain, FAT/DIFAT sector), not sectors.
|
||||
const MAX_REG_SECT: u32 = 0xFFFF_FFFA;
|
||||
/// Directory sectors followed at most (4096 entries with 512-byte sectors).
|
||||
const MAX_DIR_SECTORS: usize = 1024;
|
||||
/// DIFAT sectors followed at most.
|
||||
const MAX_DIFAT_SECTORS: usize = 1024;
|
||||
|
||||
/// Stream names that mark an encrypted (password-protected) OOXML package.
|
||||
const ENCRYPTED_STREAMS: [&str; 2] = ["EncryptedPackage", "EncryptionInfo"];
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// Whether the compound file holds an encryption stream. Walks the directory through the FAT;
|
||||
/// when the structure can't be followed (truncated or malformed), falls back to scanning the
|
||||
/// bytes for the UTF-16LE stream names.
|
||||
pub(crate) fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
match directory_names(bytes) {
|
||||
Some(names) => names.iter().any(|n| ENCRYPTED_STREAMS.iter().any(|e| n.eq_ignore_ascii_case(e))),
|
||||
None => ENCRYPTED_STREAMS.iter().any(|e| contains_utf16(bytes, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_utf16(hay: &[u8], needle: &str) -> bool {
|
||||
let n: Vec<u8> = needle.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
!n.is_empty() && hay.windows(n.len()).any(|w| w == n.as_slice())
|
||||
}
|
||||
|
||||
/// Names of every directory entry, or `None` when the directory can't be read.
|
||||
fn directory_names(b: &[u8]) -> Option<Vec<String>> {
|
||||
if !b.starts_with(&SIGNATURE) {
|
||||
return None;
|
||||
}
|
||||
let shift = u16_at(b, 30)?;
|
||||
if shift != 9 && shift != 12 {
|
||||
return None;
|
||||
}
|
||||
let size = 1usize << shift;
|
||||
let per = size / 4;
|
||||
let sector = |id: u32| -> Option<&[u8]> {
|
||||
let start = (id as usize).checked_add(1)?.checked_mul(size)?;
|
||||
b.get(start..start.checked_add(size)?)
|
||||
};
|
||||
// The DIFAT: the FAT's sector ids, 109 in the header then chained DIFAT sectors.
|
||||
let mut difat: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
|
||||
let mut next = u32_at(b, 68)?;
|
||||
for _ in 0..MAX_DIFAT_SECTORS {
|
||||
if next >= MAX_REG_SECT {
|
||||
break;
|
||||
}
|
||||
let s = sector(next)?;
|
||||
difat.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
|
||||
next = u32_at(s, (per - 1) * 4)?;
|
||||
}
|
||||
let fat_next = |id: u32| -> Option<u32> {
|
||||
let idx = id as usize;
|
||||
let fat_sector = *difat.get(idx / per)?;
|
||||
u32_at(sector(fat_sector)?, (idx % per) * 4)
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
let mut id = u32_at(b, 48)?;
|
||||
for _ in 0..MAX_DIR_SECTORS {
|
||||
if id == END_OF_CHAIN {
|
||||
return Some(names);
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return None;
|
||||
}
|
||||
let s = sector(id)?;
|
||||
for e in s.as_chunks::<128>().0 {
|
||||
let len = usize::from(u16_at(e, 64)?);
|
||||
if !(2..=64).contains(&len) {
|
||||
continue;
|
||||
}
|
||||
let units: Vec<u16> = (0..(len - 2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
|
||||
names.push(String::from_utf16_lossy(&units));
|
||||
}
|
||||
id = fat_next(id)?;
|
||||
}
|
||||
// A directory longer than the bound (or a cyclic chain): report what was read.
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// Directory entry type of a stream object ([MS-CFB] 2.6.1).
|
||||
const STREAM_OBJECT: u8 = 2;
|
||||
/// "No entry" in the directory tree (NOSTREAM).
|
||||
const NO_STREAM: u32 = 0xFFFF_FFFF;
|
||||
/// Streams larger than this are refused (the format allows more, a workbook never needs it).
|
||||
const MAX_STREAM: u64 = 1 << 30;
|
||||
|
||||
/// One directory entry ([MS-CFB] 2.6).
|
||||
struct DirEntry {
|
||||
name: String,
|
||||
kind: u8,
|
||||
left: u32,
|
||||
right: u32,
|
||||
child: u32,
|
||||
start: u32,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked.
|
||||
/// Implemented from Microsoft's public [MS-CFB] specification.
|
||||
pub(crate) struct Compound<'a> {
|
||||
bytes: &'a [u8],
|
||||
sector_size: usize,
|
||||
mini_sector_size: usize,
|
||||
mini_cutoff: u64,
|
||||
fat: Vec<u32>,
|
||||
mini_fat: Vec<u32>,
|
||||
entries: Vec<DirEntry>,
|
||||
}
|
||||
|
||||
impl<'a> Compound<'a> {
|
||||
/// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed.
|
||||
pub(crate) fn open(b: &'a [u8]) -> Result<Compound<'a>, String> {
|
||||
let bad = |m: &str| format!("damaged compound file: {m}");
|
||||
if !b.starts_with(&SIGNATURE) {
|
||||
return Err(bad("missing signature"));
|
||||
}
|
||||
let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?;
|
||||
let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?;
|
||||
if shift != 9 && shift != 12 {
|
||||
return Err(bad("unsupported sector size"));
|
||||
}
|
||||
let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?;
|
||||
if mini_shift != 6 {
|
||||
return Err(bad("unsupported mini sector size"));
|
||||
}
|
||||
let sector_size = 1usize << shift;
|
||||
let per = sector_size / 4;
|
||||
// Sectors that physically exist after the header sector: every chain is bounded by it.
|
||||
let sectors = (b.len() / sector_size).saturating_sub(1);
|
||||
let sector = |id: u32| -> Option<&[u8]> {
|
||||
let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?;
|
||||
b.get(start..start.checked_add(sector_size)?)
|
||||
};
|
||||
// DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors.
|
||||
let mut fat_sectors: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
|
||||
let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?;
|
||||
let mut hops = 0usize;
|
||||
while next < MAX_REG_SECT {
|
||||
hops += 1;
|
||||
if hops > sectors.min(MAX_DIFAT_SECTORS * 64) {
|
||||
return Err(bad("DIFAT chain loops"));
|
||||
}
|
||||
let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?;
|
||||
fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
|
||||
next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?;
|
||||
}
|
||||
if fat_sectors.len() > sectors {
|
||||
return Err(bad("more FAT sectors than the file holds"));
|
||||
}
|
||||
let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per));
|
||||
for id in fat_sectors {
|
||||
let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?;
|
||||
fat.extend((0..per).filter_map(|i| u32_at(s, i * 4)));
|
||||
}
|
||||
let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] };
|
||||
if let Some(c) = u32_at(b, 56) {
|
||||
cf.mini_cutoff = u64::from(c);
|
||||
}
|
||||
// Directory.
|
||||
let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?;
|
||||
let dir = cf.read_chain(dir_start, u64::MAX)?;
|
||||
for e in dir.as_chunks::<128>().0 {
|
||||
let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64);
|
||||
let units: Vec<u16> = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
|
||||
let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0);
|
||||
if major >= 4 {
|
||||
size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32;
|
||||
}
|
||||
cf.entries.push(DirEntry {
|
||||
name: String::from_utf16_lossy(&units),
|
||||
kind: e.get(66).copied().unwrap_or(0),
|
||||
left: u32_at(e, 68).unwrap_or(NO_STREAM),
|
||||
right: u32_at(e, 72).unwrap_or(NO_STREAM),
|
||||
child: u32_at(e, 76).unwrap_or(NO_STREAM),
|
||||
start: u32_at(e, 116).unwrap_or(END_OF_CHAIN),
|
||||
size,
|
||||
});
|
||||
}
|
||||
if cf.entries.first().is_none_or(|r| r.kind != 5) {
|
||||
return Err(bad("missing root entry"));
|
||||
}
|
||||
// Mini FAT.
|
||||
let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?;
|
||||
if mini_start < MAX_REG_SECT {
|
||||
let raw = cf.read_chain(mini_start, u64::MAX)?;
|
||||
cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect();
|
||||
}
|
||||
Ok(cf)
|
||||
}
|
||||
|
||||
/// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the
|
||||
/// FAT (so a cycle) or pointing outside the file is an error. The file may end inside the
|
||||
/// chain's last sector (some writers don't pad it, and Excel opens those files): that sector
|
||||
/// reads short, and the caller checks the stream's declared size against what was read.
|
||||
fn read_chain(&self, start: u32, limit: u64) -> Result<Vec<u8>, String> {
|
||||
let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX);
|
||||
let mut out = Vec::new();
|
||||
let mut id = start;
|
||||
let mut seen = vec![false; self.fat.len()];
|
||||
let mut cut = false;
|
||||
while id != END_OF_CHAIN && out.len() < limit {
|
||||
if cut {
|
||||
return Err("damaged compound file: sector out of range (the file may be truncated)".into());
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return Err("damaged compound file: broken sector chain".into());
|
||||
}
|
||||
match seen.get_mut(id as usize) {
|
||||
Some(v) if !*v => *v = true,
|
||||
Some(_) => return Err("damaged compound file: sector chain loops".into()),
|
||||
None => return Err("damaged compound file: sector missing from the FAT".into()),
|
||||
}
|
||||
let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size));
|
||||
let s = start
|
||||
.and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?.min(self.bytes.len())))
|
||||
.ok_or("damaged compound file: sector out of range (the file may be truncated)")?;
|
||||
cut = s.len() < self.sector_size;
|
||||
out.extend_from_slice(s);
|
||||
id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Follows a mini FAT chain inside the mini stream container. Like `read_chain`, the
|
||||
/// container may end inside the chain's last mini sector.
|
||||
fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut id = start;
|
||||
let mut seen = vec![false; self.mini_fat.len()];
|
||||
let mut cut = false;
|
||||
while id != END_OF_CHAIN && out.len() < limit {
|
||||
if cut {
|
||||
return Err("damaged compound file: mini sector out of range".into());
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return Err("damaged compound file: broken mini sector chain".into());
|
||||
}
|
||||
match seen.get_mut(id as usize) {
|
||||
Some(v) if !*v => *v = true,
|
||||
Some(_) => return Err("damaged compound file: mini sector chain loops".into()),
|
||||
None => return Err("damaged compound file: mini sector missing from the mini FAT".into()),
|
||||
}
|
||||
let at = (id as usize).checked_mul(self.mini_sector_size);
|
||||
let s = at
|
||||
.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?.min(container.len())))
|
||||
.ok_or("damaged compound file: mini sector out of range")?;
|
||||
cut = s.len() < self.mini_sector_size;
|
||||
out.extend_from_slice(s);
|
||||
id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The contents of the stream called `name` (case-insensitive) directly under the root
|
||||
/// storage, or `None` when there is no such stream.
|
||||
/// The directory entry of the stream called `name` (case-insensitive) directly under the
|
||||
/// root storage.
|
||||
fn root_entry(&self, name: &str) -> Result<Option<&DirEntry>, String> {
|
||||
let Some(root) = self.entries.first() else { return Ok(None) };
|
||||
// Walk the root's children (a red-black tree through left/right siblings), bounded.
|
||||
let mut stack = vec![root.child];
|
||||
let mut seen = 0usize;
|
||||
while let Some(i) = stack.pop() {
|
||||
if i == NO_STREAM {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if seen > self.entries.len() {
|
||||
return Err("damaged compound file: directory tree loops".into());
|
||||
}
|
||||
let Some(e) = self.entries.get(i as usize) else { continue };
|
||||
if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) {
|
||||
return Ok(Some(e));
|
||||
}
|
||||
stack.push(e.left);
|
||||
stack.push(e.right);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The contents of the stream called `name` (case-insensitive) directly under the root
|
||||
/// storage, or `None` when there is no such stream.
|
||||
pub(crate) fn root_stream(&self, name: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let Some(root) = self.entries.first() else { return Ok(None) };
|
||||
let found = self.root_entry(name)?;
|
||||
let Some(e) = found else { return Ok(None) };
|
||||
if e.size > MAX_STREAM {
|
||||
return Err("the workbook stream is larger than 1 GiB".into());
|
||||
}
|
||||
let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?;
|
||||
let mut data = if e.size < self.mini_cutoff {
|
||||
let container = self.read_chain(root.start, root.size)?;
|
||||
self.read_mini_chain(&container, e.start, size)?
|
||||
} else {
|
||||
self.read_chain(e.start, e.size)?
|
||||
};
|
||||
if data.len() < size {
|
||||
return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into());
|
||||
}
|
||||
data.truncate(size);
|
||||
Ok(Some(data))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal valid compound file (512-byte sectors: FAT at sector 0, directory at sector 1)
|
||||
/// whose directory holds a root entry plus the given stream names.
|
||||
pub(crate) fn build(streams: &[&str]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 512 * 3];
|
||||
b[..8].copy_from_slice(&SIGNATURE);
|
||||
b[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); // minor version
|
||||
b[26..28].copy_from_slice(&3u16.to_le_bytes()); // major version
|
||||
b[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); // byte order
|
||||
b[30..32].copy_from_slice(&9u16.to_le_bytes()); // sector shift
|
||||
b[32..34].copy_from_slice(&6u16.to_le_bytes()); // mini sector shift
|
||||
b[44..48].copy_from_slice(&1u32.to_le_bytes()); // FAT sectors
|
||||
b[48..52].copy_from_slice(&1u32.to_le_bytes()); // first directory sector
|
||||
b[60..64].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no mini FAT
|
||||
b[68..72].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
b[76 + i * 4..80 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[76..80].copy_from_slice(&0u32.to_le_bytes()); // FAT lives in sector 0
|
||||
// FAT (sector 0, at byte 512): sector 0 is a FAT sector, sector 1 ends its chain.
|
||||
for i in 0..128 {
|
||||
b[512 + i * 4..516 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[512..516].copy_from_slice(&0xFFFF_FFFDu32.to_le_bytes());
|
||||
b[516..520].copy_from_slice(&END_OF_CHAIN.to_le_bytes());
|
||||
// Directory (sector 1, at byte 1024).
|
||||
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).take(4).enumerate() {
|
||||
let e = 1024 + k * 128;
|
||||
let units: Vec<u16> = name.encode_utf16().take(31).collect();
|
||||
for (i, u) in units.iter().enumerate() {
|
||||
b[e + i * 2..e + i * 2 + 2].copy_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
b[e + 64..e + 66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes());
|
||||
b[e + 66] = if k == 0 { 5 } else { 2 };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_directory_names() {
|
||||
let b = build(&["EncryptionInfo", "EncryptedPackage"]);
|
||||
assert_eq!(directory_names(&b), Some(vec!["Root Entry".into(), "EncryptionInfo".into(), "EncryptedPackage".into()]));
|
||||
assert!(is_encrypted(&b));
|
||||
assert!(!is_encrypted(&build(&["Workbook", "\u{5}SummaryInformation"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_never_panic() {
|
||||
let mut b = build(&["Workbook"]);
|
||||
for at in [30usize, 48, 68, 76, 512, 516, 1024 + 64] {
|
||||
for v in [0u32, 1, 7, 0xFFFF_FFFA, 0xFFFF_FFFF, 0x7FFF_FFFF] {
|
||||
let mut c = b.clone();
|
||||
c[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
let _ = is_encrypted(&c);
|
||||
for cut in [0usize, 8, 100, 600, 1100] {
|
||||
let _ = is_encrypted(&c[..cut.min(c.len())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A directory chain that loops back on itself stops at the bound.
|
||||
b[516..520].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert!(!is_encrypted(&b));
|
||||
}
|
||||
|
||||
/// `compound_file` with one regular-sector stream of `len` bytes (FAT in sector 0, directory
|
||||
/// in sector 1, the stream from sector 2), cut right after the stream's last byte, the way
|
||||
/// some writers leave the final sector unpadded.
|
||||
fn unpadded(len: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let data: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Workbook", &data)]);
|
||||
file.truncate(512 * 3 + len);
|
||||
(file, data)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpadded_last_sector_still_reads() {
|
||||
let (file, data) = unpadded(5000);
|
||||
assert_ne!(file.len() % 512, 0);
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data);
|
||||
// Bytes the stream needs that the file doesn't hold are still damage.
|
||||
let cf = Compound::open(&file[..file.len() - 1]).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpadded_last_mini_sector_still_reads() {
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Workbook", &data)]);
|
||||
// Header, FAT, directory, mini FAT, then the mini stream holding the stream's bytes.
|
||||
file.truncate(512 * 4 + data.len());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data);
|
||||
let cf = Compound::open(&file[..file.len() - 1]).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_sector_inside_a_chain_is_damage() {
|
||||
let (mut file, _) = unpadded(5000);
|
||||
// The cut sector (11) points on to the directory sector and the stream claims those
|
||||
// bytes too: the partial sector would splice 392 bytes into the middle of the stream.
|
||||
file[512 + 11 * 4..512 + 12 * 4].copy_from_slice(&1u32.to_le_bytes());
|
||||
file[1024 + 128 + 120..1024 + 128 + 124].copy_from_slice(&5512u32.to_le_bytes());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_mini_sector_inside_a_chain_is_damage() {
|
||||
let small = [7u8; 100];
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Small", &small), ("Workbook", &data)]);
|
||||
// "Small" holds mini sectors 0-1, "Workbook" 2-17; the mini stream starts at sector 3.
|
||||
file.truncate(512 * 4 + 128 + data.len());
|
||||
assert_eq!(Compound::open(&file).unwrap().root_stream("Workbook").unwrap().unwrap(), data);
|
||||
// The cut mini sector (17) points on to sector 0 and the stream claims those bytes too.
|
||||
file[1536 + 17 * 4..1536 + 18 * 4].copy_from_slice(&0u32.to_le_bytes());
|
||||
file[1024 + 2 * 128 + 120..1024 + 2 * 128 + 124].copy_from_slice(&1064u32.to_le_bytes());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
}
|
||||
+19
-511
@@ -1,42 +1,7 @@
|
||||
//! Password-encrypted workbooks. Excel wraps the ordinary OOXML package in a compound file with
|
||||
//! two streams: `EncryptionInfo` (how the key comes from the password, and a verifier that tells
|
||||
//! a wrong password) and `EncryptedPackage` (the package, encrypted with AES). Agile encryption
|
||||
//! (Excel 2010 onward) and Standard encryption (Excel 2007) are read; RC4, Extensible and
|
||||
//! certificate-only encryption are reported as unsupported.
|
||||
//!
|
||||
//! Clean-room from Microsoft's public [MS-OFFCRYPTO] specification. Every read is
|
||||
//! bounds-checked, and the costly inputs (spin count, stated sizes) are capped.
|
||||
|
||||
use aes::cipher::{BlockCipherDecrypt, KeyInit};
|
||||
use sha2::Digest;
|
||||
|
||||
use crate::cfb::Compound;
|
||||
use crate::xml::El;
|
||||
|
||||
/// Spin counts above this are refused (the cap sheet-protection hashes use too).
|
||||
const MAX_SPIN_COUNT: u32 = 10_000_000;
|
||||
/// Standard encryption always iterates this many times.
|
||||
const STANDARD_SPIN_COUNT: u32 = 50_000;
|
||||
/// Agile packages are encrypted in segments of this many bytes, each with its own IV.
|
||||
pub(crate) const SEGMENT: usize = 4096;
|
||||
/// AES block size.
|
||||
const BLOCK: usize = 16;
|
||||
|
||||
/// Agile block keys ([MS-OFFCRYPTO] 2.3.4.13): one per value derived from the password.
|
||||
pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79];
|
||||
pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E];
|
||||
pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6];
|
||||
/// The two dataIntegrity block keys ([MS-OFFCRYPTO] 2.3.4.14), one per HMAC field.
|
||||
pub(crate) const BLOCK_HMAC_KEY: [u8; 8] = [0x5F, 0xB2, 0xAD, 0x01, 0x0C, 0xB9, 0xE1, 0xF6];
|
||||
pub(crate) const BLOCK_HMAC_VALUE: [u8; 8] = [0xA0, 0x67, 0x7F, 0x02, 0xB2, 0x2C, 0x84, 0x33];
|
||||
|
||||
/// The password key encryptor's URI (the other kind is certificate-based).
|
||||
pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
|
||||
|
||||
/// Standard encryption header flags ([MS-OFFCRYPTO] 2.3.1).
|
||||
pub(crate) const F_CRYPTOAPI: u32 = 0x04;
|
||||
const F_EXTERNAL: u32 = 0x10;
|
||||
pub(crate) const F_AES: u32 = 0x20;
|
||||
//! two streams, `EncryptionInfo` and `EncryptedPackage`. `craft-offcrypto` reads them (Agile and
|
||||
//! Standard encryption; RC4, Extensible, certificate-only and rights-managed files are named as
|
||||
//! unsupported); this module keeps GridCraft's error type.
|
||||
|
||||
/// Why an encrypted workbook couldn't be decrypted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -50,489 +15,32 @@ pub enum DecryptError {
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
fn malformed(m: impl Into<String>) -> DecryptError {
|
||||
DecryptError::Malformed(m.into())
|
||||
impl From<craft_offcrypto::Error> for DecryptError {
|
||||
fn from(e: craft_offcrypto::Error) -> Self {
|
||||
use craft_offcrypto::Error as E;
|
||||
match e {
|
||||
E::WrongPassword | E::PasswordRequired => DecryptError::WrongPassword,
|
||||
E::Unsupported(scheme) => DecryptError::Unsupported(scheme),
|
||||
E::Malformed(detail) | E::TooLarge(detail) => DecryptError::Malformed(detail),
|
||||
e => DecryptError::Malformed(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain OOXML package (a zip) inside an encrypted workbook.
|
||||
pub fn decrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let (info, package) = streams(bytes)?;
|
||||
decrypt_streams(&info, &package, password)
|
||||
craft_offcrypto::decrypt(bytes, Some(password)).map_err(DecryptError::from)
|
||||
}
|
||||
|
||||
/// Whether the right password could decrypt this encrypted workbook, checked without one. An
|
||||
/// error names a scheme no password opens (RC4, rights management…) or damage, so callers can
|
||||
/// say so instead of asking for a password first.
|
||||
pub fn check_encryption(bytes: &[u8]) -> Result<(), DecryptError> {
|
||||
let (info, _) = streams(bytes)?;
|
||||
match scheme(&info)? {
|
||||
Scheme::Agile(xml) => agile_descriptor(xml).map(|_| ()),
|
||||
Scheme::Standard(info) => standard_header(info).map(|_| ()),
|
||||
}
|
||||
craft_offcrypto::check(bytes).map_err(DecryptError::from)
|
||||
}
|
||||
|
||||
/// The `EncryptionInfo` and `EncryptedPackage` streams. Rights management (IRM) keeps the
|
||||
/// package but describes its encryption elsewhere, with no `EncryptionInfo`.
|
||||
fn streams(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), DecryptError> {
|
||||
let cf = Compound::open(bytes).map_err(malformed)?;
|
||||
let info = cf.root_stream("EncryptionInfo").map_err(malformed)?;
|
||||
let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?;
|
||||
let info = info.ok_or_else(|| DecryptError::Unsupported("rights-management".into()))?;
|
||||
Ok((info, package))
|
||||
/// Whether a compound file holds an encryption stream, at any depth. A damaged directory falls
|
||||
/// back to a search for the stream names, so the file is still reported as encrypted.
|
||||
pub(crate) fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
["EncryptedPackage", "EncryptionInfo"].iter().any(|n| craft_cfb::has_entry_named(bytes, n, &craft_cfb::Limits::default()))
|
||||
}
|
||||
|
||||
/// Decrypts from the two streams' contents.
|
||||
pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
match scheme(info)? {
|
||||
Scheme::Agile(xml) => agile(xml, package, password),
|
||||
Scheme::Standard(info) => standard(info, package, password),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two schemes read, with the part of `EncryptionInfo` each one parses.
|
||||
enum Scheme<'a> {
|
||||
/// The XML descriptor after the version and reserved field.
|
||||
Agile(&'a [u8]),
|
||||
/// Everything after the version.
|
||||
Standard(&'a [u8]),
|
||||
}
|
||||
|
||||
fn scheme(info: &[u8]) -> Result<Scheme<'_>, DecryptError> {
|
||||
let short = || malformed("truncated EncryptionInfo");
|
||||
let major = u16_at(info, 0).ok_or_else(short)?;
|
||||
let minor = u16_at(info, 2).ok_or_else(short)?;
|
||||
match (major, minor) {
|
||||
(4, 4) => Ok(Scheme::Agile(info.get(8..).ok_or_else(short)?)),
|
||||
(2..=4, 2) => Ok(Scheme::Standard(info.get(4..).ok_or_else(short)?)),
|
||||
(3 | 4, 3) => Err(DecryptError::Unsupported("Extensible".into())),
|
||||
_ => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// The plaintext size an `EncryptedPackage` states, and the ciphertext after it. A size larger
|
||||
/// than the ciphertext is damage, so it never drives an allocation.
|
||||
fn package_parts(package: &[u8]) -> Result<(usize, &[u8]), DecryptError> {
|
||||
let head: [u8; 8] = package.get(..8).and_then(|h| h.try_into().ok()).ok_or_else(|| malformed("truncated EncryptedPackage"))?;
|
||||
let data = package.get(8..).unwrap_or_default();
|
||||
let size = usize::try_from(u64::from_le_bytes(head)).unwrap_or(usize::MAX);
|
||||
if size > data.len() {
|
||||
return Err(malformed("the package is shorter than its stated size"));
|
||||
}
|
||||
Ok((size, data))
|
||||
}
|
||||
|
||||
// ---- hashing -------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Hash {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha384,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
impl Hash {
|
||||
fn from_name(name: &str) -> Result<Hash, DecryptError> {
|
||||
Ok(match name {
|
||||
"SHA1" | "SHA-1" => Hash::Sha1,
|
||||
"SHA256" | "SHA-256" => Hash::Sha256,
|
||||
"SHA384" | "SHA-384" => Hash::Sha384,
|
||||
"SHA512" | "SHA-512" => Hash::Sha512,
|
||||
other => return Err(DecryptError::Unsupported(format!("{other} hash"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// The digest of the concatenated `parts`.
|
||||
pub(crate) fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
|
||||
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
for p in parts {
|
||||
d.update(p);
|
||||
}
|
||||
d.finalize().to_vec()
|
||||
}
|
||||
match self {
|
||||
Hash::Sha1 => run::<sha1::Sha1>(parts),
|
||||
Hash::Sha256 => run::<sha2::Sha256>(parts),
|
||||
Hash::Sha384 => run::<sha2::Sha384>(parts),
|
||||
Hash::Sha512 => run::<sha2::Sha512>(parts),
|
||||
}
|
||||
}
|
||||
|
||||
/// The digest's length in bytes.
|
||||
fn digest_len(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha1 => 20,
|
||||
Hash::Sha256 => 32,
|
||||
Hash::Sha384 => 48,
|
||||
Hash::Sha512 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64.
|
||||
fn block_size(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha384 | Hash::Sha512 => 128,
|
||||
Hash::Sha1 | Hash::Sha256 => 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds.
|
||||
pub(crate) fn iterated_hash(hash: Hash, password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
|
||||
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut h = hash.digest(&[salt, &pw]);
|
||||
for i in 0..spin {
|
||||
h = hash.digest(&[&i.to_le_bytes(), &h]);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// H(a ‖ b), truncated or padded with 0x36 to `len`: Agile's derived keys and IVs.
|
||||
pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec<u8> {
|
||||
let mut k = hash.digest(&[a, b]);
|
||||
k.resize(len, 0x36);
|
||||
k
|
||||
}
|
||||
|
||||
/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses.
|
||||
pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec<u8> {
|
||||
let bs = hash.block_size();
|
||||
let mut k = key.to_vec();
|
||||
if k.len() > bs {
|
||||
k = hash.digest(&[&k]);
|
||||
}
|
||||
k.resize(bs, 0);
|
||||
let pad = |x: u8| k.iter().map(|b| b ^ x).collect::<Vec<u8>>();
|
||||
let inner = hash.digest(&[&pad(0x36), message]);
|
||||
hash.digest(&[&pad(0x5C), &inner])
|
||||
}
|
||||
|
||||
/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the
|
||||
/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`.
|
||||
pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec<u8> {
|
||||
let h = iterated_hash(Hash::Sha1, password, salt, STANDARD_SPIN_COUNT);
|
||||
let h = Hash::Sha1.digest(&[&h, &0u32.to_le_bytes()]);
|
||||
let expand = |pad: u8| {
|
||||
let mut buf = [pad; 64];
|
||||
for (b, x) in buf.iter_mut().zip(&h) {
|
||||
*b ^= x;
|
||||
}
|
||||
Hash::Sha1.digest(&[&buf])
|
||||
};
|
||||
let mut key = expand(0x36);
|
||||
key.extend(expand(0x5C));
|
||||
key.truncate(key_len);
|
||||
key
|
||||
}
|
||||
|
||||
// ---- AES -----------------------------------------------------------------------------------
|
||||
|
||||
enum Aes {
|
||||
A128(aes::Aes128),
|
||||
A192(aes::Aes192),
|
||||
A256(aes::Aes256),
|
||||
}
|
||||
|
||||
impl Aes {
|
||||
fn new(key: &[u8]) -> Result<Aes, DecryptError> {
|
||||
let bad = |_| malformed("bad AES key");
|
||||
Ok(match key.len() {
|
||||
16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(bad)?),
|
||||
24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(bad)?),
|
||||
32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(bad)?),
|
||||
n => return Err(malformed(format!("a {n}-byte AES key"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_block(&self, block: &mut [u8; BLOCK]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match self {
|
||||
Aes::A128(c) => c.decrypt_block(b),
|
||||
Aes::A192(c) => c.decrypt_block(b),
|
||||
Aes::A256(c) => c.decrypt_block(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// ECB: each block on its own. `data` must be whole blocks.
|
||||
fn decrypt_ecb(&self, data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
blocks.iter_mut().for_each(|b| self.decrypt_block(b));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CBC: each block XORed with the previous ciphertext block (the IV for the first).
|
||||
fn decrypt_cbc(&self, iv: &[u8], data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| malformed("an IV that isn't one AES block"))?;
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
for b in blocks {
|
||||
let cipher = *b;
|
||||
self.decrypt_block(b);
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
prev = cipher;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Standard encryption -------------------------------------------------------------------
|
||||
|
||||
/// What a Standard `EncryptionInfo` holds: the key size and the password verifier.
|
||||
struct StandardHeader<'a> {
|
||||
key_len: usize,
|
||||
salt: &'a [u8],
|
||||
verifier: &'a [u8],
|
||||
verifier_hash: &'a [u8],
|
||||
}
|
||||
|
||||
/// `info` starts after the version: flags, header size, header, verifier.
|
||||
fn standard_header(info: &[u8]) -> Result<StandardHeader<'_>, DecryptError> {
|
||||
let short = || malformed("truncated Standard encryption header");
|
||||
let flags = u32_at(info, 0).ok_or_else(short)?;
|
||||
if flags & F_EXTERNAL != 0 {
|
||||
return Err(DecryptError::Unsupported("external".into()));
|
||||
}
|
||||
if flags & F_AES == 0 || flags & F_CRYPTOAPI == 0 {
|
||||
return Err(DecryptError::Unsupported("RC4".into()));
|
||||
}
|
||||
let header_size = usize::try_from(u32_at(info, 4).ok_or_else(short)?).map_err(|_| short())?;
|
||||
let header = info.get(8..8usize.checked_add(header_size).ok_or_else(short)?).ok_or_else(short)?;
|
||||
let alg_id = u32_at(header, 8).ok_or_else(short)?;
|
||||
let alg_hash = u32_at(header, 12).ok_or_else(short)?;
|
||||
let key_bits = u32_at(header, 16).ok_or_else(short)?;
|
||||
let key_len = match (alg_id, key_bits) {
|
||||
(0x660E | 0, 128) => 16,
|
||||
(0x660F, 192) => 24,
|
||||
(0x6610, 256) => 32,
|
||||
(0x660E..=0x6610, _) => return Err(malformed(format!("AES with a {key_bits}-bit key"))),
|
||||
_ => return Err(DecryptError::Unsupported(format!("cipher 0x{alg_id:04X}"))),
|
||||
};
|
||||
if alg_hash != 0x8004 && alg_hash != 0 {
|
||||
return Err(DecryptError::Unsupported(format!("hash 0x{alg_hash:04X}")));
|
||||
}
|
||||
// Verifier: salt size, salt, encrypted verifier, verifier hash size, encrypted hash.
|
||||
let v = info.get(8 + header_size..).ok_or_else(short)?;
|
||||
if u32_at(v, 0) != Some(16) {
|
||||
return Err(malformed("a Standard salt that isn't 16 bytes"));
|
||||
}
|
||||
let salt = v.get(4..20).ok_or_else(short)?;
|
||||
let verifier = v.get(20..36).ok_or_else(short)?;
|
||||
let verifier_hash = v.get(40..72).ok_or_else(short)?;
|
||||
Ok(StandardHeader { key_len, salt, verifier, verifier_hash })
|
||||
}
|
||||
|
||||
fn standard(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let h = standard_header(info)?;
|
||||
let (mut verifier, mut verifier_hash) = (h.verifier.to_vec(), h.verifier_hash.to_vec());
|
||||
let aes = Aes::new(&standard_key(password, h.salt, h.key_len))?;
|
||||
aes.decrypt_ecb(&mut verifier)?;
|
||||
aes.decrypt_ecb(&mut verifier_hash)?;
|
||||
if verifier_hash.get(..20) != Some(Hash::Sha1.digest(&[&verifier]).as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = data.get(..size.div_ceil(BLOCK) * BLOCK).ok_or_else(|| malformed("the package isn't whole AES blocks"))?.to_vec();
|
||||
aes.decrypt_ecb(&mut out)?;
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- Agile encryption ----------------------------------------------------------------------
|
||||
|
||||
/// The cipher parameters shared by `keyData` and a password `encryptedKey` element.
|
||||
pub(crate) struct Params {
|
||||
pub(crate) hash: Hash,
|
||||
pub(crate) salt: Vec<u8>,
|
||||
pub(crate) key_len: usize,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn read(e: &El) -> Result<Params, DecryptError> {
|
||||
let cipher = e.attr("cipherAlgorithm").unwrap_or("AES");
|
||||
if cipher != "AES" {
|
||||
return Err(DecryptError::Unsupported(cipher.into()));
|
||||
}
|
||||
let chaining = e.attr("cipherChaining").unwrap_or("ChainingModeCBC");
|
||||
if chaining != "ChainingModeCBC" {
|
||||
return Err(DecryptError::Unsupported(format!("AES {chaining}")));
|
||||
}
|
||||
if e.attr_u32("blockSize") != Some(16) {
|
||||
return Err(malformed("an AES block size that isn't 16"));
|
||||
}
|
||||
let key_len = match e.attr_u32("keyBits") {
|
||||
Some(128) => 16,
|
||||
Some(192) => 24,
|
||||
Some(256) => 32,
|
||||
other => return Err(malformed(format!("AES with keyBits {}", other.unwrap_or(0)))),
|
||||
};
|
||||
let hash = Hash::from_name(e.attr("hashAlgorithm").unwrap_or("SHA1"))?;
|
||||
let salt = base64(e.attr("saltValue").ok_or_else(|| malformed("no saltValue"))?)?;
|
||||
if salt.is_empty() || salt.len() > 64 {
|
||||
return Err(malformed("a salt that isn't 1 to 64 bytes"));
|
||||
}
|
||||
Ok(Params { hash, salt, key_len })
|
||||
}
|
||||
|
||||
/// An IV: H(salt ‖ block key) for a block key, else the salt, sized to one AES block.
|
||||
pub(crate) fn iv(&self, block_key: Option<&[u8]>) -> Vec<u8> {
|
||||
match block_key {
|
||||
Some(k) => derive(self.hash, &self.salt, k, BLOCK),
|
||||
None => {
|
||||
let mut iv = self.salt.clone();
|
||||
iv.resize(BLOCK, 0x36);
|
||||
iv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an Agile descriptor holds: the package's cipher, the password key encryptor with its
|
||||
/// three encrypted values (verifier input, verifier hash, package key), and the package's
|
||||
/// integrity pair, which Excel always writes and the reader requires.
|
||||
struct AgileDescriptor {
|
||||
key_data: Params,
|
||||
key: Params,
|
||||
spin: u32,
|
||||
verifier_input: Vec<u8>,
|
||||
verifier_hash: Vec<u8>,
|
||||
key_value: Vec<u8>,
|
||||
hmac_key: Vec<u8>,
|
||||
hmac_value: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
|
||||
fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
|
||||
let root = crate::xml::parse(xml).map_err(|e| malformed(e.to_string()))?;
|
||||
let key_data = Params::read(root.child("keyData").ok_or_else(|| malformed("no keyData"))?)?;
|
||||
let encryptor = root
|
||||
.child("keyEncryptors")
|
||||
.and_then(|k| k.kids("keyEncryptor").find(|e| e.attr("uri") == Some(PASSWORD_ENCRYPTOR)))
|
||||
.ok_or_else(|| DecryptError::Unsupported("certificate".into()))?
|
||||
.child("encryptedKey")
|
||||
.ok_or_else(|| malformed("no encryptedKey"))?;
|
||||
let key = Params::read(encryptor)?;
|
||||
let spin = encryptor.attr_u32("spinCount").ok_or_else(|| malformed("no spinCount"))?;
|
||||
if spin > MAX_SPIN_COUNT {
|
||||
return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed")));
|
||||
}
|
||||
let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?);
|
||||
// A descriptor without dataIntegrity can't prove its ciphertext is the one the password
|
||||
// protects: the verifier only authenticates the key, not the package ([MS-OFFCRYPTO] 2.3.4.14).
|
||||
let integrity = root.child("dataIntegrity").ok_or_else(|| malformed("no dataIntegrity"))?;
|
||||
let hmac_field = |attr: &str| {
|
||||
let v = base64(integrity.attr(attr).ok_or_else(|| malformed(format!("no {attr}")))?)?;
|
||||
if v.is_empty() || v.len() % BLOCK != 0 {
|
||||
return Err(malformed(format!("a whole-block {attr}")));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
Ok(AgileDescriptor {
|
||||
key_data,
|
||||
key,
|
||||
spin,
|
||||
verifier_input: field("encryptedVerifierHashInput")?,
|
||||
verifier_hash: field("encryptedVerifierHashValue")?,
|
||||
key_value: field("encryptedKeyValue")?,
|
||||
hmac_key: hmac_field("encryptedHmacKey")?,
|
||||
hmac_value: hmac_field("encryptedHmacValue")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the
|
||||
/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments.
|
||||
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let descriptor = agile_descriptor(xml)?;
|
||||
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor;
|
||||
let h = iterated_hash(key.hash, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |data: &[u8], block_key: &[u8]| -> Result<Vec<u8>, DecryptError> {
|
||||
let mut data = data.to_vec();
|
||||
Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?;
|
||||
Ok(data)
|
||||
};
|
||||
let input = field(&verifier_input, &BLOCK_VERIFIER_INPUT)?;
|
||||
let expected = field(&verifier_hash, &BLOCK_VERIFIER_HASH)?;
|
||||
let input = input.get(..key.salt.len()).unwrap_or(&input);
|
||||
let actual = key.hash.digest(&[input]);
|
||||
if expected.get(..actual.len()) != Some(actual.as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let secret = field(&key_value, &BLOCK_KEY)?;
|
||||
let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?;
|
||||
// Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the
|
||||
// package key (the verifier above only authenticates the password), computed over the whole
|
||||
// EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a
|
||||
// random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so
|
||||
// both lengths are tried.
|
||||
let mut mac_field = hmac_key;
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?;
|
||||
let mut declared = hmac_value;
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?;
|
||||
let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| {
|
||||
let Some(key) = mac_field.get(..len) else { return false };
|
||||
let mac = hmac(key_data.hash, key, package);
|
||||
declared.get(..mac.len()) == Some(mac.as_slice())
|
||||
});
|
||||
if !verified {
|
||||
return Err(malformed("the decrypted data failed its integrity check"));
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
for (i, segment) in data.chunks(SEGMENT).enumerate() {
|
||||
if out.len() >= size {
|
||||
break;
|
||||
}
|
||||
let index = u32::try_from(i).map_err(|_| malformed("too many segments"))?;
|
||||
let start = out.len();
|
||||
out.extend_from_slice(segment);
|
||||
let block = out.get_mut(start..).unwrap_or_default();
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&index.to_le_bytes())), block)?;
|
||||
}
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- base64 --------------------------------------------------------------------------------
|
||||
|
||||
/// Standard base64 (whitespace ignored), as Agile's XML uses for every binary value.
|
||||
fn base64(s: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut buf, mut bits) = (0u32, 0u32);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => return Err(malformed("invalid base64")),
|
||||
};
|
||||
buf = (buf << 6) | u32::from(v);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
buf &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
//! Decryption tests. The expected keys and IVs below were computed independently with
|
||||
//! msoffcrypto-tool 6.0.0 (MIT) for the synthetic password and salts used here; the encrypted
|
||||
//! files are built in code by `crate::testing`. No Excel-produced file is used.
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{agile_info, encrypt_agile, encrypt_agile_with_hmac_key, encrypt_standard, with_agile_header};
|
||||
|
||||
const PASSWORD: &str = "Pass1234";
|
||||
/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values
|
||||
/// can't be reached anyway (scheme errors, descriptor-parse damage).
|
||||
const HMAC_STUB: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn salt() -> Vec<u8> {
|
||||
(0u8..16).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_decrypts_the_fips_197_vectors() {
|
||||
let mut b: [u8; 16] = hex("69c4e0d86a7b0430d8cdb78070b4c55a").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
let mut b: [u8; 16] = hex("8ea2b7ca516745bfeafc49904b496089").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_keys_match_msoffcrypto() {
|
||||
let h = iterated_hash(Hash::Sha512, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_VERIFIER_INPUT, 32), hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6"));
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_KEY, 32), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03"));
|
||||
let h = iterated_hash(Hash::Sha1, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha1, &h, &BLOCK_VERIFIER_INPUT, 16), hex("88c7567d028eae33a9a55f65729dcd2b"));
|
||||
let key_data = Params { hash: Hash::Sha512, salt: (16u8..32).collect(), key_len: 32 };
|
||||
assert_eq!(key_data.iv(Some(&1u32.to_le_bytes())), hex("da22ccba062aff577d207b2dee4e165b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_pads_short_hashes_with_0x36() {
|
||||
// SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow.
|
||||
let k = derive(Hash::Sha1, b"a", b"b", 32);
|
||||
assert_eq!(k[..20], Hash::Sha1.digest(&[b"ab"])[..]);
|
||||
assert_eq!(k[20..], [0x36; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_keys_match_msoffcrypto() {
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 16), hex("ae0b402aeb23a779471a130843e431d4"));
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 32), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_and_rejects_junk() {
|
||||
assert_eq!(base64("AAECAwQFBgcICQoLDA0ODw==").unwrap(), salt());
|
||||
assert_eq!(base64(" AAEC\nAw== ").unwrap(), vec![0, 1, 2, 3]);
|
||||
assert!(matches!(base64("AA*C"), Err(DecryptError::Malformed(_))));
|
||||
}
|
||||
|
||||
/// A payload that isn't a whole number of segments or blocks.
|
||||
fn payload(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i * 7 + i / 251) as u8).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_standard(&plain, PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_round_trip() {
|
||||
// UTF-16LE, surrogate pairs included: "Pässwörd€" plus an emoji outside the BMP.
|
||||
let pw = "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}";
|
||||
let (info, package) = encrypt_agile(&payload(100), pw, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
assert_eq!(decrypt_streams(&info, &package, "Passwrd"), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), pw);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_password_is_reported_as_such() {
|
||||
let (info, package) = encrypt_agile(&payload(100), PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, "pass1234"), Err(DecryptError::WrongPassword));
|
||||
assert_eq!(decrypt_streams(&info, &package, ""), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, "Pass12345"), Err(DecryptError::WrongPassword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_schemes_are_named() {
|
||||
let unsupported = |info: &[u8]| match decrypt_streams(info, &[0; 8], PASSWORD) {
|
||||
Err(DecryptError::Unsupported(s)) => s,
|
||||
other => panic!("expected Unsupported, got {other:?}"),
|
||||
};
|
||||
let (mut rc4, _) = encrypt_standard(&payload(10), PASSWORD);
|
||||
rc4[4..8].copy_from_slice(&F_CRYPTOAPI.to_le_bytes()); // fCryptoAPI without fAES
|
||||
assert_eq!(unsupported(&rc4), "RC4");
|
||||
assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible");
|
||||
assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1");
|
||||
let des = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
|
||||
assert_eq!(unsupported(&with_agile_header(&des)), "3DES");
|
||||
let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("keyEncryptor/password", "keyEncryptor/certificate");
|
||||
assert_eq!(unsupported(&with_agile_header(&cert)), "certificate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_malformed_not_panics() {
|
||||
let is_malformed = |r: Result<Vec<u8>, DecryptError>| matches!(r, Err(DecryptError::Malformed(_)));
|
||||
let plain = payload(5000);
|
||||
for (info, package) in [encrypt_agile(&plain, PASSWORD, 1000), encrypt_standard(&plain, PASSWORD)] {
|
||||
// Every truncation of either stream.
|
||||
for cut in (0..info.len()).step_by(7) {
|
||||
let _ = decrypt_streams(&info[..cut], &package, PASSWORD);
|
||||
}
|
||||
for cut in (0..package.len()).step_by(97) {
|
||||
let _ = decrypt_streams(&info, &package[..cut], PASSWORD);
|
||||
}
|
||||
// A stated size past the ciphertext never drives an allocation.
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &huge, PASSWORD)));
|
||||
// Ciphertext that isn't whole AES blocks.
|
||||
let mut ragged = package.clone();
|
||||
ragged.truncate(ragged.len() - 3);
|
||||
let len = (ragged.len() - 8) as u64;
|
||||
ragged[..8].copy_from_slice(&len.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD)));
|
||||
}
|
||||
let xml = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB);
|
||||
let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD));
|
||||
assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\"")));
|
||||
assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*")));
|
||||
assert!(bad(xml.replace("keyBits=\"256\"", "keyBits=\"100\"")));
|
||||
assert!(bad(xml.replace("blockSize=\"16\"", "blockSize=\"8\"")));
|
||||
assert!(bad(xml.replace("<keyData", "<other")));
|
||||
assert!(bad("<encryption".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
|
||||
// "Pässwörd€" plus an emoji outside the BMP, a surrogate pair in UTF-16.
|
||||
let h = iterated_hash(Hash::Sha512, "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}", &salt(), 1000);
|
||||
assert_eq!(
|
||||
h,
|
||||
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_matches_the_rfc2104_test_vectors() {
|
||||
use crate::crypto::hmac as mac;
|
||||
let h = |v: Vec<u8>| v.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
// RFC 2202 test case 1 (HMAC-SHA-1) and HMAC-SHA-256 vectors.
|
||||
assert_eq!(h(mac(Hash::Sha1, &[0x0b; 20], b"Hi There")), "b617318655057264e28bc0b6fb378c8ef146be00");
|
||||
assert_eq!(h(mac(Hash::Sha256, &[0x0b; 20], b"Hi There")), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7");
|
||||
// RFC 4231 test case 5 (HMAC-SHA-256): a key longer than the block size is hashed first.
|
||||
assert_eq!(
|
||||
h(mac(Hash::Sha256, &[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
|
||||
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
|
||||
);
|
||||
}
|
||||
|
||||
/// Strips the `<dataIntegrity …/>` element from an Agile descriptor.
|
||||
fn without_integrity(info: &[u8]) -> Vec<u8> {
|
||||
let xml = std::str::from_utf8(&info[8..]).unwrap().to_string();
|
||||
let start = xml.find("<dataIntegrity").unwrap();
|
||||
let end = start + xml[start..].find("/>").unwrap() + 2;
|
||||
let stripped = format!("{}{}", &xml[..start], &xml[end..]);
|
||||
with_agile_header(&stripped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_is_required_and_enforced() {
|
||||
let plain = payload(300);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
// An attacker can't just delete the integrity element to get the package verified.
|
||||
assert!(matches!(decrypt_streams(&without_integrity(&info), &package, PASSWORD),
|
||||
Err(DecryptError::Malformed(m)) if m.contains("dataIntegrity")));
|
||||
// An empty or ragged field is damage, not a pass.
|
||||
for bad in [("encryptedHmacKey", ""), ("encryptedHmacKey", "AA=="), ("encryptedHmacValue", "")] {
|
||||
let xml =
|
||||
agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace(&format!("{}=\"{HMAC_STUB}\"", bad.0), &format!("{}=\"{}\"", bad.0, bad.1));
|
||||
let r = decrypt_streams(&with_agile_header(&xml), &package, PASSWORD);
|
||||
assert!(matches!(r, Err(DecryptError::Malformed(_))), "{}=\"{}\"", bad.0, bad.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_ciphertext_fails_the_integrity_check() {
|
||||
let plain = payload(300);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
// The correct password proves the verifier can't vouch for a modified package.
|
||||
for at in [8usize, 100, package.len() - 5] {
|
||||
let mut tampered = package.clone();
|
||||
tampered[at] ^= 0x80;
|
||||
assert!(
|
||||
matches!(decrypt_streams(&info, &tampered, PASSWORD),
|
||||
Err(DecryptError::Malformed(m)) if m.contains("integrity")),
|
||||
"byte {at}"
|
||||
);
|
||||
}
|
||||
// And neither can the descriptor: a swapped integrity value is refused.
|
||||
let xml = String::from_utf8_lossy(&info[8..]).to_string();
|
||||
let at = xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len();
|
||||
let mut xml: Vec<u8> = xml.into_bytes();
|
||||
xml[at] ^= b'A' ^ b'B';
|
||||
assert!(matches!(decrypt_streams(&with_agile_header(std::str::from_utf8(&xml).unwrap()), &package, PASSWORD), Err(DecryptError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_matches_the_stream_not_the_plaintext() {
|
||||
// The MAC covers the whole EncryptedPackage stream, StreamSize field included: changing the
|
||||
// stated size alone must fail, while the actual bytes round-trip.
|
||||
let plain = payload(4200);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
let mut ours = package.clone();
|
||||
ours[..8].copy_from_slice(&((plain.len() + 1) as u64).to_le_bytes());
|
||||
assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() {
|
||||
// Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size
|
||||
// key. Both verify, and neither lets a modified package through.
|
||||
let plain = payload(5000);
|
||||
for key in [vec![7u8; 64], vec![7u8; 16]] {
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len());
|
||||
let mut tampered = package.clone();
|
||||
tampered[100] ^= 1;
|
||||
assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
// Another length (a key that matches neither) fails.
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]);
|
||||
assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
+10
-251
@@ -1,4 +1,6 @@
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency).
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency). Decoding, delimiter
|
||||
//! sniffing, the record parser and field quoting are `craft-csv`'s; this module turns records into
|
||||
//! typed cells (with each column's date order) and writes a sheet.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,241 +14,11 @@ use crate::{CsvOptions, IoError, ReadReport};
|
||||
/// The largest CSV or tab-separated file written (bytes).
|
||||
pub const MAX_CSV_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1).
|
||||
const CP1252: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
|
||||
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
pub use craft_csv::{decode_text, sniff_delimiter};
|
||||
|
||||
/// Windows-1256 code points for bytes 0x80–0xFF (unicode.org `CP1256.TXT`).
|
||||
pub(crate) const CP1256: [u32; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252.
|
||||
#[derive(Default)]
|
||||
struct Cp1256Evidence {
|
||||
/// High bytes that decode to different characters in the two code pages, apart from invisible
|
||||
/// marks and ordinals after a digit.
|
||||
differing: usize,
|
||||
/// Of those, Arabic letters and Arabic punctuation (، ؛ ؟) in Windows-1256.
|
||||
arabic: usize,
|
||||
/// Arabic bytes next to an ASCII letter: Western accents sit inside Latin words, Arabic doesn't.
|
||||
arabic_touching_latin: usize,
|
||||
/// Accented Latin bytes the two code pages share, next to another high byte: Hebrew
|
||||
/// Windows-1255 letters land there, Arabic and Western text rarely do.
|
||||
shared_letters_in_runs: usize,
|
||||
}
|
||||
|
||||
impl Cp1256Evidence {
|
||||
fn of(bytes: &[u8]) -> Self {
|
||||
let mut e = Self::default();
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if b < 0x80 {
|
||||
continue;
|
||||
}
|
||||
let before = i.checked_sub(1).and_then(|j| bytes.get(j)).copied();
|
||||
let after = bytes.get(i + 1).copied();
|
||||
let next_to = |pred: fn(u8) -> bool| before.is_some_and(pred) || after.is_some_and(pred);
|
||||
if same_in_cp1252_and_cp1256(b) {
|
||||
if is_shared_latin_letter(b) && next_to(|x| x >= 0x80) {
|
||||
e.shared_letters_in_runs += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_invisible_mark(b) || is_ordinal_after_digit(b, before) {
|
||||
continue;
|
||||
}
|
||||
e.differing += 1;
|
||||
if is_cp1256_arabic(b) {
|
||||
e.arabic += 1;
|
||||
if next_to(|x| x.is_ascii_alphabetic()) {
|
||||
e.arabic_touching_latin += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Thresholds chosen against month, day and language names from 95 glibc locales (Western in
|
||||
/// Windows-1252, Arabic, Persian and Urdu in 1256, Hebrew and Yiddish in 1255) plus CSV samples.
|
||||
fn is_arabic(&self) -> bool {
|
||||
self.arabic >= 2
|
||||
&& self.arabic * 10 >= self.differing * 9
|
||||
&& self.arabic_touching_latin * 10 <= self.arabic * 3
|
||||
&& self.shared_letters_in_runs * 20 <= self.arabic
|
||||
}
|
||||
}
|
||||
|
||||
fn same_in_cp1252_and_cp1256(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x80 | 0x82..=0x89 | 0x8B..=0x8C | 0x91..=0x97 | 0x99 | 0x9B..=0x9C | 0xA0 | 0xA2..=0xA9 | 0xAB..=0xB9 | 0xBB..=0xBE | 0xD7 | 0xE0 | 0xE2 | 0xE7..=0xEB
|
||||
| 0xEE..=0xEF | 0xF4 | 0xF7 | 0xF9 | 0xFB..=0xFC
|
||||
)
|
||||
}
|
||||
|
||||
/// ZWNJ, ZWJ, LRM and RLM: common inside Persian words and around Arabic cells, but no evidence
|
||||
/// either way.
|
||||
fn is_invisible_mark(b: u8) -> bool {
|
||||
matches!(b, 0x9D | 0x9E | 0xFD | 0xFE)
|
||||
}
|
||||
|
||||
/// `º` and `ª` after a digit (`1º`, `2ª`) are Western ordinals, though Windows-1256 reads the bytes
|
||||
/// as `؛` and `ھ`; either way they are no evidence.
|
||||
fn is_ordinal_after_digit(b: u8, before: Option<u8>) -> bool {
|
||||
matches!(b, 0xAA | 0xBA) && before.is_some_and(|x| x.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_shared_latin_letter(b: u8) -> bool {
|
||||
matches!(b, 0xE0 | 0xE2 | 0xE7..=0xEB | 0xEE..=0xEF | 0xF4 | 0xF9 | 0xFB..=0xFC)
|
||||
}
|
||||
|
||||
fn is_cp1256_arabic(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x81 | 0x8A | 0x8D..=0x90 | 0x98 | 0x9A | 0x9F | 0xA1 | 0xAA | 0xBA | 0xBF..=0xD6 | 0xD8..=0xDB | 0xDD..=0xDF | 0xE1 | 0xE3..=0xE6 | 0xEC..=0xED | 0xFF
|
||||
)
|
||||
}
|
||||
|
||||
/// Decodes CSV bytes with BOM-aware UTF-8 and UTF-16 handling. Without a BOM, valid UTF-8 is kept.
|
||||
/// Other bytes decode as Windows-1256 when they read as Arabic (see [`Cp1256Evidence`]), else as
|
||||
/// Windows-1252.
|
||||
pub fn decode_text(bytes: &[u8]) -> String {
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
let utf16 = |b: &[u8], le: bool| -> String {
|
||||
let units = b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) });
|
||||
char::decode_utf16(units).map(|r| r.unwrap_or('\u{FFFD}')).collect()
|
||||
};
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
|
||||
return utf16(rest, true);
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return utf16(rest, false);
|
||||
}
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => {
|
||||
let use_windows_1256 = Cp1256Evidence::of(bytes).is_arabic();
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&b| match b {
|
||||
0x80..=0xFF if use_windows_1256 => {
|
||||
char::from_u32(CP1256.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
|
||||
_ => b as char,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks comma, semicolon or tab: the candidate with the most consistent count per line
|
||||
/// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins.
|
||||
pub fn sniff_delimiter(text: &str, quote: char) -> u8 {
|
||||
let mut best = (b',', 0usize, 0usize);
|
||||
for d in [',', ';', '\t'] {
|
||||
let mut counts = vec![];
|
||||
let mut n = 0usize;
|
||||
let mut in_q = false;
|
||||
let mut blank = true;
|
||||
for ch in text.chars().take(64 * 1024) {
|
||||
if !in_q && ch == '\n' {
|
||||
// Blank lines carry no delimiter evidence.
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
n = 0;
|
||||
blank = true;
|
||||
if counts.len() >= 20 {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch != '\r' {
|
||||
blank = false;
|
||||
}
|
||||
if ch == quote {
|
||||
in_q = !in_q;
|
||||
} else if !in_q && ch == d {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
let min = counts.iter().copied().min().unwrap_or(0);
|
||||
let total: usize = counts.iter().sum();
|
||||
if min > 0 && (min, total) > (best.1, best.2) {
|
||||
best = (d as u8, min, total);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are
|
||||
/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field.
|
||||
pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
let mut field = String::new();
|
||||
let mut record: Vec<String> = vec![];
|
||||
let mut in_q = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut field_started_quoted = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if in_q {
|
||||
if c == quote {
|
||||
if chars.peek() == Some("e) {
|
||||
field.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = false;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == quote && field.is_empty() && !field_started_quoted {
|
||||
in_q = true;
|
||||
field_started_quoted = true;
|
||||
} else if c == delim {
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
} else if c == '\r' || c == '\n' {
|
||||
if c == '\r' && chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
if !on_record(std::mem::take(&mut record)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
}
|
||||
if !field.is_empty() || !record.is_empty() || field_started_quoted {
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(field);
|
||||
}
|
||||
on_record(record);
|
||||
}
|
||||
/// [`craft_csv::parse_records`] with a record kept to the grid's [`MAX_COLS`] fields.
|
||||
fn parse_records(text: &str, delim: char, quote: char, on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
craft_csv::parse_records(text, delim, quote, MAX_COLS as usize, on_record);
|
||||
}
|
||||
|
||||
/// How many rows and bytes of a text file are read to decide each column's date order.
|
||||
@@ -587,25 +359,12 @@ pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_te
|
||||
out.push(delim);
|
||||
}
|
||||
let t = cell_text(CellRef::new(row, col));
|
||||
let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r');
|
||||
// The exact size of the field as written (the delimiter was pushed above), so a row
|
||||
// never grows past the cap unseen and no doubled-quote copy of the text is built.
|
||||
let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() };
|
||||
if out.len().saturating_add(field_len) > limit {
|
||||
// never grows past the cap unseen.
|
||||
if out.len().saturating_add(craft_csv::field_len(&t, delim)) > limit {
|
||||
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
|
||||
}
|
||||
if quoted {
|
||||
out.push('"');
|
||||
for ch in t.chars() {
|
||||
if ch == '"' {
|
||||
out.push('"');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push('"');
|
||||
} else {
|
||||
out.push_str(&t);
|
||||
}
|
||||
craft_csv::push_field(&mut out, &t, delim);
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
if out.len() > limit {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod array_formula;
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod chartex;
|
||||
mod controls;
|
||||
@@ -272,8 +271,8 @@ pub fn sniff(bytes: &[u8]) -> Format {
|
||||
// CFB/OLE2 compound file: a password-protected workbook when it holds an encryption
|
||||
// stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another
|
||||
// legacy binary (.doc…).
|
||||
if bytes.starts_with(&cfb::SIGNATURE) {
|
||||
return if cfb::is_encrypted(bytes) {
|
||||
if bytes.starts_with(&craft_cfb::SIGNATURE) {
|
||||
return if crypto::is_encrypted(bytes) {
|
||||
Format::Encrypted
|
||||
} else if xls::is_xls(bytes) {
|
||||
Format::Xls
|
||||
|
||||
+6
-263
@@ -1,282 +1,25 @@
|
||||
//! Test support: builds password-encrypted workbooks in code, so tests here and in the crates
|
||||
//! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and,
|
||||
//! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped).
|
||||
//!
|
||||
//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real
|
||||
//! use: a fixed key protects nothing.
|
||||
//! The encryption is `craft-offcrypto`'s and the compound file `craft-cfb`'s.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
|
||||
use crate::crypto::{
|
||||
BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params,
|
||||
SEGMENT, derive, hmac, iterated_hash, standard_key,
|
||||
};
|
||||
|
||||
/// The spin count `encrypt_workbook` uses: Excel's default.
|
||||
pub const SPIN_COUNT: u32 = 100_000;
|
||||
|
||||
const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||
const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
||||
const SECRET_KEY: [u8; 32] =
|
||||
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
|
||||
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
|
||||
/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the
|
||||
/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes.
|
||||
const HMAC_KEY: [u8; 64] = [
|
||||
112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140,
|
||||
141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
|
||||
170, 171, 172, 173, 174, 175,
|
||||
];
|
||||
|
||||
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
|
||||
/// (AES-256, SHA-512) inside a compound file.
|
||||
pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
craft_offcrypto::encrypt_with_spin_count(package, password, SPIN_COUNT).unwrap()
|
||||
}
|
||||
|
||||
/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it.
|
||||
pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_standard(package, password);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
craft_offcrypto::encrypt_standard(package, password, 128).unwrap()
|
||||
}
|
||||
|
||||
/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as
|
||||
/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular
|
||||
/// sectors. Up to 109 FAT sectors (about 7 MB of streams).
|
||||
/// A version 3 compound file (512-byte sectors) holding `streams` under the root: streams under
|
||||
/// 4096 bytes in the mini stream, larger ones in regular sectors.
|
||||
pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
const SECTOR: usize = 512;
|
||||
const MINI: usize = 64;
|
||||
const CUTOFF: usize = 4096;
|
||||
const FREE: u32 = 0xFFFF_FFFF;
|
||||
const END: u32 = 0xFFFF_FFFE;
|
||||
const FAT_SECTOR: u32 = 0xFFFF_FFFD;
|
||||
let is_mini = |d: &[u8]| d.len() < CUTOFF;
|
||||
|
||||
// The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT.
|
||||
let mut container = vec![];
|
||||
let mut mini_fat: Vec<u32> = vec![];
|
||||
let mut mini_starts = vec![];
|
||||
for (_, d) in streams {
|
||||
if !is_mini(d) || d.is_empty() {
|
||||
mini_starts.push(END);
|
||||
continue;
|
||||
}
|
||||
let first = mini_fat.len();
|
||||
let n = d.len().div_ceil(MINI);
|
||||
for k in 0..n {
|
||||
mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 });
|
||||
}
|
||||
mini_starts.push(first as u32);
|
||||
container.extend_from_slice(d);
|
||||
container.resize((first + n) * MINI, 0);
|
||||
}
|
||||
|
||||
// Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream.
|
||||
let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR;
|
||||
let mut blobs: Vec<Vec<u8>> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()];
|
||||
blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() }));
|
||||
let counts: Vec<usize> = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect();
|
||||
let body: usize = counts.iter().sum();
|
||||
let mut fat_sectors = 1;
|
||||
while fat_sectors * 128 < fat_sectors + body {
|
||||
fat_sectors += 1;
|
||||
}
|
||||
assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors");
|
||||
let mut fat = vec![FREE; fat_sectors * 128];
|
||||
fat[..fat_sectors].fill(FAT_SECTOR);
|
||||
let mut starts = vec![];
|
||||
let mut next = fat_sectors;
|
||||
for &n in &counts {
|
||||
if n == 0 {
|
||||
starts.push(END);
|
||||
continue;
|
||||
}
|
||||
starts.push(next as u32);
|
||||
for k in 0..n {
|
||||
fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 };
|
||||
}
|
||||
next += n;
|
||||
}
|
||||
|
||||
let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes());
|
||||
let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
// Directory: the root (owning the mini stream), then the streams chained as right siblings.
|
||||
let dir = &mut blobs[0];
|
||||
for k in 0..dir_len / 128 {
|
||||
for at in [68, 72, 76] {
|
||||
put32(dir, k * 128 + at, FREE);
|
||||
}
|
||||
}
|
||||
let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| {
|
||||
let e = k * 128;
|
||||
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
dir[e..e + units.len()].copy_from_slice(&units);
|
||||
put16(dir, e + 64, units.len() as u16 + 2);
|
||||
dir[e + 66] = kind;
|
||||
dir[e + 67] = 1; // black
|
||||
if kind == 5 && !streams.is_empty() {
|
||||
put32(dir, e + 76, 1);
|
||||
}
|
||||
if kind == 2 && k < streams.len() {
|
||||
put32(dir, e + 72, k as u32 + 1);
|
||||
}
|
||||
put32(dir, e + 116, start);
|
||||
put32(dir, e + 120, size as u32);
|
||||
};
|
||||
entry(0, "Root Entry", 5, starts[2], container.len());
|
||||
for (k, (name, data)) in streams.iter().enumerate() {
|
||||
let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] };
|
||||
entry(k + 1, name, 2, start, data.len());
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)];
|
||||
out[..8].copy_from_slice(&crate::cfb::SIGNATURE);
|
||||
put16(&mut out, 24, 0x3E); // minor version
|
||||
put16(&mut out, 26, 3); // major version
|
||||
put16(&mut out, 28, 0xFFFE); // byte order
|
||||
put16(&mut out, 30, 9); // 512-byte sectors
|
||||
put16(&mut out, 32, 6); // 64-byte mini sectors
|
||||
put32(&mut out, 44, fat_sectors as u32);
|
||||
put32(&mut out, 48, starts[0]);
|
||||
put32(&mut out, 56, CUTOFF as u32);
|
||||
put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none)
|
||||
put32(&mut out, 64, counts[1] as u32);
|
||||
put32(&mut out, 68, END); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE });
|
||||
}
|
||||
for (i, v) in fat.iter().enumerate() {
|
||||
put32(&mut out, SECTOR + i * 4, *v);
|
||||
}
|
||||
for (blob, start) in blobs.iter().zip(&starts) {
|
||||
if !blob.is_empty() {
|
||||
let at = SECTOR * (1 + *start as usize);
|
||||
out[at..at + blob.len()].copy_from_slice(blob);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn encrypt_block(key: &[u8], block: &mut [u8; 16]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match key.len() {
|
||||
16 => aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
n => panic!("no {n}-byte AES in the test encryptor"),
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(data: &[u8]) -> Vec<u8> {
|
||||
let mut v = data.to_vec();
|
||||
v.resize(data.len().div_ceil(16) * 16, 0);
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b));
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
let mut prev: [u8; 16] = iv.try_into().unwrap();
|
||||
for b in v.as_chunks_mut::<16>().0 {
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
encrypt_block(key, b);
|
||||
prev = *b;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn base64_encode(b: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
|
||||
let mut p = (size as u64).to_le_bytes().to_vec();
|
||||
p.extend(body);
|
||||
p
|
||||
}
|
||||
|
||||
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16, and the
|
||||
/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries.
|
||||
pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> String {
|
||||
let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 };
|
||||
let h = iterated_hash(Hash::Sha512, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data));
|
||||
let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER);
|
||||
let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER]));
|
||||
let secret = field(&BLOCK_KEY, &SECRET_KEY);
|
||||
let params = |salt: &[u8]| {
|
||||
format!(
|
||||
"saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"",
|
||||
base64_encode(salt)
|
||||
)
|
||||
};
|
||||
// The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key.
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><dataIntegrity encryptedHmacKey=\"{hmac_key_b64}\" encryptedHmacValue=\"{hmac_value_b64}\"/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
|
||||
params(KEY_DATA_SALT.as_slice()),
|
||||
params(&PASSWORD_SALT),
|
||||
)
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML.
|
||||
pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
|
||||
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
|
||||
info.extend_from_slice(xml.as_bytes());
|
||||
info
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the
|
||||
/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it.
|
||||
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
|
||||
encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY)
|
||||
}
|
||||
|
||||
/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`.
|
||||
pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
|
||||
let mut body = vec![];
|
||||
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
|
||||
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
|
||||
}
|
||||
let stream = package(plain.len(), body);
|
||||
let (hmac_key_b64, hmac_value_b64) = (
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)),
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))),
|
||||
);
|
||||
(with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream)
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1).
|
||||
pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let key = standard_key(password, &PASSWORD_SALT, 16);
|
||||
let mut header = vec![];
|
||||
for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] {
|
||||
header.extend_from_slice(&u32::to_le_bytes(v));
|
||||
}
|
||||
header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes));
|
||||
let mut info = vec![4, 0, 2, 0];
|
||||
info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes());
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
info.extend(header);
|
||||
info.extend_from_slice(&16u32.to_le_bytes());
|
||||
info.extend(PASSWORD_SALT);
|
||||
info.extend(encrypt_ecb(&key, &VERIFIER));
|
||||
info.extend_from_slice(&20u32.to_le_bytes());
|
||||
info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER])));
|
||||
(info, package(plain.len(), encrypt_ecb(&key, plain)))
|
||||
craft_cfb::write_streams(craft_cfb::Version::V3, streams).unwrap()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::csv::{decode_text, parse_records, sniff_delimiter};
|
||||
use crate::{CsvOptions, Format, IoError, read_csv, read_csv_with_report, sniff, write_csv, write_csv_limited};
|
||||
use craft_csv::{decode_text, parse_records, sniff_delimiter};
|
||||
|
||||
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
|
||||
wb.sheet(0).unwrap().value(CellRef::new(r, c))
|
||||
@@ -148,33 +148,6 @@ fn encodings() {
|
||||
assert_eq!(v(&wb, 0, 1), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1256_table_matches_the_unicode_mapping() {
|
||||
let at = |b: u8| crate::csv::CP1256.get(usize::from(b - 0x80)).and_then(|&c| char::from_u32(c));
|
||||
let expected = [
|
||||
(0x80, '€'),
|
||||
(0x81, 'پ'),
|
||||
(0x8D, 'چ'),
|
||||
(0x98, 'ک'),
|
||||
(0x9D, '\u{200C}'),
|
||||
(0xA1, '،'),
|
||||
(0xBA, '؛'),
|
||||
(0xBF, '؟'),
|
||||
(0xC7, 'ا'),
|
||||
(0xC8, 'ب'),
|
||||
(0xDC, 'ـ'),
|
||||
(0xE1, 'ل'),
|
||||
(0xE3, 'م'),
|
||||
(0xE9, 'é'),
|
||||
(0xFD, '\u{200E}'),
|
||||
(0xFE, '\u{200F}'),
|
||||
(0xFF, 'ے'),
|
||||
];
|
||||
for (b, c) in expected {
|
||||
assert_eq!(at(b), Some(c), "0x{b:02X}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_csv_in_windows_1256_reads_as_arabic() {
|
||||
let bytes = [
|
||||
@@ -302,8 +275,9 @@ fn sniffing_formats() {
|
||||
assert_eq!(sniff(b"a,b\n1,2"), Format::Csv);
|
||||
// An Excel password-protected file is a CFB/OLE2 compound file with an EncryptedPackage
|
||||
// stream; any other compound file is a legacy binary (.xls), not an encrypted workbook.
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["EncryptionInfo", "EncryptedPackage"])), Format::Encrypted);
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["Workbook"])), Format::LegacyBinary);
|
||||
let compound = crate::testing::compound_file;
|
||||
assert_eq!(sniff(&compound(&[("EncryptionInfo", b"x"), ("EncryptedPackage", b"x")])), Format::Encrypted);
|
||||
assert_eq!(sniff(&compound(&[("WordDocument", b"x")])), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0]), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0, 0, 1, 2]), Format::Unknown);
|
||||
assert_eq!(sniff(b""), Format::Unknown);
|
||||
@@ -393,14 +367,14 @@ fn parser_stops_collecting_fields_at_max_cols() {
|
||||
let mut text = ",".repeat((MAX_COLS + extra) as usize - 1);
|
||||
text.push_str("end\nx,y\n");
|
||||
let mut lens = vec![];
|
||||
parse_records(&text, ',', '"', |rec| {
|
||||
parse_records(&text, ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
assert_eq!(lens, vec![MAX_COLS as usize, 2]);
|
||||
// An unterminated last row is capped the same way.
|
||||
let mut lens = vec![];
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| {
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
|
||||
@@ -3,24 +3,11 @@
|
||||
use crate::testing::{compound_file, encrypt_workbook, encrypt_workbook_standard};
|
||||
use crate::{DecryptError, decrypt};
|
||||
|
||||
#[test]
|
||||
fn the_test_compound_writer_reads_back() {
|
||||
let big: Vec<u8> = (0..3_000_000u32).map(|i| (i % 253) as u8).collect();
|
||||
let mid: Vec<u8> = (0..4095u32).map(|i| i as u8).collect();
|
||||
let file = compound_file(&[("Small", b"hello"), ("Big", &big), ("Empty", b""), ("Mid", &mid)]);
|
||||
let cf = crate::cfb::Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Mid").unwrap().unwrap(), mid, "just under the cutoff: the mini stream");
|
||||
assert_eq!(cf.root_stream("Small").unwrap().unwrap(), b"hello");
|
||||
assert_eq!(cf.root_stream("big").unwrap().unwrap(), big);
|
||||
assert_eq!(cf.root_stream("Empty").unwrap().unwrap(), b"");
|
||||
assert_eq!(cf.root_stream("Missing").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_files_decrypt_to_the_package() {
|
||||
let package: Vec<u8> = (0..3_000_000u32).map(|i| (i * 31 % 251) as u8).collect();
|
||||
let agile = encrypt_workbook(&package, "Pass1234");
|
||||
assert!(crate::cfb::is_encrypted(&agile));
|
||||
assert!(crate::crypto::is_encrypted(&agile));
|
||||
assert_eq!(decrypt(&agile, "Pass1234").unwrap(), package);
|
||||
assert_eq!(decrypt(&agile, "wrong"), Err(DecryptError::WrongPassword));
|
||||
let standard = encrypt_workbook_standard(&package, "Pass1234");
|
||||
@@ -49,16 +36,19 @@ fn a_gridcraft_workbook_survives_encryption() {
|
||||
#[test]
|
||||
fn schemes_no_password_opens_are_named_before_asking_for_one() {
|
||||
use crate::check_encryption;
|
||||
use crate::testing::{agile_info, with_agile_header};
|
||||
let unsupported = |s: &str| Err(DecryptError::Unsupported(s.into()));
|
||||
assert_eq!(check_encryption(&encrypt_workbook(b"package", "pw")), Ok(()));
|
||||
assert_eq!(check_encryption(&encrypt_workbook_standard(b"package", "pw")), Ok(()));
|
||||
let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]);
|
||||
assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4"));
|
||||
assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible"));
|
||||
let cert =
|
||||
agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate");
|
||||
assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate"));
|
||||
// An Agile descriptor whose only key encryptor is a certificate.
|
||||
let agile = encrypt_workbook(b"package", "pw");
|
||||
let cf = craft_cfb::CompoundFile::open(&agile).unwrap();
|
||||
let info = cf.read(cf.child(0, "EncryptionInfo").unwrap()).unwrap();
|
||||
let mut cert = info[..8].to_vec();
|
||||
cert.extend(String::from_utf8(info[8..].to_vec()).unwrap().replace("keyEncryptor/password", "keyEncryptor/certificate").into_bytes());
|
||||
assert_eq!(check_encryption(&file(&cert)), unsupported("certificate"));
|
||||
assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_))));
|
||||
// Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo.
|
||||
let irm = compound_file(&[("EncryptedPackage", &[0; 8])]);
|
||||
|
||||
Reference in New Issue
Block a user