- 1
//! Bounded, lossless Open XML package engine - 2
//! (docs/design/72-openxml-documents.md). - 3
//! - 4
//! Every package is hostile input (O2): this crate enforces its own byte, - 5
//! entry, ratio, depth and attribute bounds, because the process it runs in - 6
//! may have no OS sandbox at all (F11). It depends on no other vak crate so - 7
//! it can be tested without a server, a model or a sandbox, and every vak - 8
//! call site runs it inside the broker worker (invariant 14). - 9
//! - 10
//! Layers present so far: - 11
//! - [`package`]: L0, the OPC reader, content types, relationship graph, - 12
//! format detection, security inspection, and the raw-copy writer (O1). - 13
//! - [`xml`]: L1 reading, a bounded event walk that refuses `DOCTYPE`. - 14
//! - [`read`]: L2 read projections with anchors and O6 labels for Word, - 15
//! Excel, PowerPoint and Visio. - 16
//! - [`edit`]: L3, the typed op set and its one apply engine. - 17
//! - [`blank`]: the built-in blank packages a file created from scratch - 18
//! starts from. - 19
- 20
pub mod blank; - 21
pub mod diff; - 22
pub mod edit; - 23
#[cfg(feature = "fixtures")] - 24
pub mod fixtures; - 25
pub mod package; - 26
pub mod projection; - 27
pub mod read; - 28
pub mod review; - 29
pub mod splice; - 30
pub mod xml; - 31
- 32
pub use package::{ - 33
Conformance, ExternalRelationship, Format, FormatKind, Inspection, Package, Relationship, - 34
Vocabulary, - 35
}; - 36
- 37
use std::fmt; - 38
- 39
/// Bounds applied to one package. Byte bounds are checked against the - 40
/// bytes actually decompressed, never only against the sizes a ZIP - 41
/// directory declares. - 42
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 43
pub struct Limits { - 44
pub max_entries: usize, - 45
pub max_part_bytes: u64, - 46
pub max_total_bytes: u64, - 47
/// Largest declared expansion ratio accepted for a part bigger than - 48
/// [`Limits::ratio_floor_bytes`]; small parts legitimately compress well. - 49
pub max_ratio: u64, - 50
pub ratio_floor_bytes: u64, - 51
pub max_xml_depth: usize, - 52
pub max_attributes: usize, - 53
pub max_relationship_parts: usize, - 54
} - 55
- 56
impl Default for Limits { - 57
fn default() -> Self { - 58
Self { - 59
max_entries: 10_000, - 60
max_part_bytes: 64 * 1024 * 1024, - 61
max_total_bytes: 256 * 1024 * 1024, - 62
max_ratio: 200, - 63
ratio_floor_bytes: 1024 * 1024, - 64
max_xml_depth: 256, - 65
max_attributes: 256, - 66
max_relationship_parts: 5_000, - 67
} - 68
} - 69
} - 70
- 71
#[derive(Debug, Clone, PartialEq, Eq)] - 72
pub enum Error { - 73
Io(String), - 74
/// An OLE compound file: an encrypted Open XML package or a legacy - 75
/// binary Office file. Neither is readable yet. - 76
CompoundFile, - 77
NotZip(String), - 78
TooManyEntries(usize), - 79
InvalidPartName(String), - 80
DuplicatePart(String), - 81
EncryptedEntry(String), - 82
UnsupportedCompression(String), - 83
PartTooLarge(String), - 84
TotalTooLarge, - 85
CompressionRatio(String), - 86
MissingPart(String), - 87
Xml { - 88
part: String, - 89
message: String, - 90
}, - 91
DocType(String), - 92
TooDeep(String), - 93
TooManyAttributes(String), - 94
NoMainPart, - 95
RelationshipEscape(String), - 96
UnsupportedFormat(String), - 97
} - 98
- 99
impl fmt::Display for Error { - 100
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - 101
match self { - 102
Error::Io(message) => write!(f, "package could not be read: {message}"), - 103
Error::CompoundFile => f.write_str( - 104
"file is an OLE compound file: an encrypted Open XML package or a legacy \ - 105
binary Office format (.doc/.xls/.ppt/.vsd); neither can be read yet", - 106
), - 107
Error::NotZip(message) => { - 108
write!( - 109
f, - 110
"file is not an Open XML package (not a ZIP archive): {message}" - 111
) - 112
} - 113
Error::TooManyEntries(count) => { - 114
write!(f, "package has {count} entries, over the entry limit") - 115
} - 116
Error::InvalidPartName(name) => write!(f, "package has an invalid part name {name:?}"), - 117
Error::DuplicatePart(name) => { - 118
write!( - 119
f, - 120
"package has duplicate part {name:?} (names are case-insensitive)" - 121
) - 122
} - 123
Error::EncryptedEntry(name) => write!(f, "package entry {name:?} is ZIP-encrypted"), - 124
Error::UnsupportedCompression(name) => { - 125
write!( - 126
f, - 127
"package entry {name:?} uses an unsupported compression method" - 128
) - 129
} - 130
Error::PartTooLarge(name) => write!(f, "part {name:?} exceeds the per-part size limit"), - 131
Error::TotalTooLarge => { - 132
f.write_str("package exceeds the total decompressed size limit") - 133
} - 134
Error::CompressionRatio(name) => { - 135
write!(f, "part {name:?} exceeds the compression-ratio limit") - 136
} - 137
Error::MissingPart(name) => write!(f, "package is missing required part {name:?}"), - 138
Error::Xml { part, message } => write!(f, "part {part:?} is not valid XML: {message}"), - 139
Error::DocType(part) => { - 140
write!( - 141
f, - 142
"part {part:?} declares a DOCTYPE, which Open XML never uses" - 143
) - 144
} - 145
Error::TooDeep(part) => write!(f, "part {part:?} exceeds the XML depth limit"), - 146
Error::TooManyAttributes(part) => { - 147
write!(f, "part {part:?} has an element over the attribute limit") - 148
} - 149
Error::NoMainPart => { - 150
f.write_str("package has no officeDocument relationship to a main part") - 151
} - 152
Error::RelationshipEscape(target) => { - 153
write!(f, "relationship target {target:?} escapes the package") - 154
} - 155
Error::UnsupportedFormat(content_type) => { - 156
write!( - 157
f, - 158
"main part content type {content_type:?} is not a supported Open XML format" - 159
) - 160
} - 161
} - 162
} - 163
} - 164
- 165
impl std::error::Error for Error {} - 166
- 167
/// Lowercased file extensions of the Open XML family this crate opens. - 168
pub const EXTENSIONS: &[&str] = &[ - 169
"docx", "docm", "dotx", "dotm", "xlsx", "xlsm", "xltx", "xltm", "xlam", "pptx", "pptm", "potx", - 170
"potm", "ppsx", "ppsm", "ppam", "vsdx", "vsdm", "vstx", "vstm", "vssx", "vssm", - 171
]; - 172
- 173
/// True when `path` names a file of the Open XML family by its extension. - 174
/// Detection is by content type once opened; this only routes a path. - 175
pub fn is_openxml_path(path: &str) -> bool { - 176
path.rsplit_once('.') - 177
.map(|(_, extension)| EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())) - 178
.unwrap_or(false) - 179
} - 180
- 181
/// True when `anchor` has the shape of an anchor the reader returns - 182
/// (`p:1A2B3C4D`, `p@12`, `p@12/comment:3`, `tbl@1`, `tbl@1/r2`, - 183
/// `Budget!B4`, `'Q4 plan'!A5:B5`, `Budget!`, `slide:256`, - 184
/// `slide:256/shape:3`, `slide:256/placeholder:title`, `slide:256/notes`, - 185
/// `page:0/shape:5`). It checks shape only: whether the anchor exists is a - 186
/// question for a read of the file. A defined name's anchor is the bare name - 187
/// and has no shape to check. - 188
pub fn is_anchor(anchor: &str) -> bool { - 189
if anchor.is_empty() || anchor.len() > 300 || anchor.chars().any(char::is_control) { - 190
return false; - 191
} - 192
let digits = |text: &str| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()); - 193
if let Some((paragraph, id)) = anchor.split_once("/comment:") { - 194
return (paragraph.starts_with("p:") || paragraph.starts_with("p@")) - 195
&& is_anchor(paragraph) - 196
&& digits(id); - 197
} - 198
if let Some(rest) = anchor.strip_prefix("tbl@") { - 199
let (table, row) = rest.split_once('/').unwrap_or((rest, "")); - 200
return digits(table) && (row.is_empty() || row.strip_prefix('r').is_some_and(digits)); - 201
} - 202
if let Some(id) = anchor.strip_prefix("p:") { - 203
return (1..=8).contains(&id.len()) && id.bytes().all(|byte| byte.is_ascii_hexdigit()); - 204
} - 205
if let Some(index) = anchor.strip_prefix("p@") { - 206
return digits(index); - 207
} - 208
if let Some(rest) = anchor.strip_prefix("slide:") { - 209
let (id, part) = rest.split_once('/').unwrap_or((rest, "")); - 210
return digits(id) - 211
&& (part.is_empty() - 212
|| part == "notes" - 213
|| part.strip_prefix("shape:").is_some_and(digits) - 214
|| part.strip_prefix("placeholder:").is_some_and(|name| { - 215
!name.is_empty() && name.bytes().all(|byte| byte.is_ascii_alphanumeric()) - 216
})); - 217
} - 218
if let Some(rest) = anchor.strip_prefix("page:") { - 219
let (id, part) = rest.split_once('/').unwrap_or((rest, "")); - 220
return digits(id) && (part.is_empty() || part.strip_prefix("shape:").is_some_and(digits)); - 221
} - 222
let Some((sheet, cells)) = anchor.rsplit_once('!') else { - 223
return false; - 224
}; - 225
let sheet_ok = match sheet.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { - 226
Some(quoted) => !quoted.is_empty() && !quoted.replace("''", "").contains('\''), - 227
None => { - 228
!sheet.is_empty() - 229
&& sheet - 230
.chars() - 231
.all(|c| c.is_alphanumeric() || c == '_' || c == '.') - 232
} - 233
}; - 234
let cell = |text: &str| { - 235
let letters = text.bytes().take_while(u8::is_ascii_alphabetic).count(); - 236
(1..=3).contains(&letters) && digits(&text[letters..]) - 237
}; - 238
sheet_ok - 239
&& (cells.is_empty() - 240
|| match cells.split_once(':') { - 241
Some((from, to)) => cell(from) && cell(to), - 242
None => cell(cells), - 243
}) - 244
} - 245
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.