use anyhow::{anyhow, bail, Context, Result};
use quick_xml::events::{BytesEnd, BytesStart, Event};
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
pub(crate) fn local_name_eq(name: &[u8], local: &[u8]) -> bool {
let n = match name.iter().rposition(|&b| b == b':') {
Some(i) => &name[i + 1..],
None => name,
};
n == local
}
#[derive(Debug, Clone)]
pub struct CellEdit {
pub sheet: String,
pub row: i32,
pub col: i32,
pub formula: Option<String>,
pub value: CellValue,
}
#[derive(Debug, Clone)]
pub enum CellValue {
Number(f64),
Str(String),
Bool(bool),
Blank,
}
pub fn surgical_write(input: &[u8], edits: &[CellEdit]) -> Result<Vec<u8>> {
let workbook_xml = read_part(input, "xl/workbook.xml")?;
let rels_xml = read_part(input, "xl/_rels/workbook.xml.rels")?;
let mut by_part: BTreeMap<String, Vec<&CellEdit>> = BTreeMap::new();
for edit in edits {
let part = resolve_sheet_part(&workbook_xml, &rels_xml, &edit.sheet)
.with_context(|| format!("resolve sheet {}", edit.sheet))?;
by_part.entry(part).or_default().push(edit);
}
let mut archive =
zip::ZipArchive::new(Cursor::new(input)).map_err(|e| anyhow!("open workbook zip: {e}"))?;
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let base_opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.last_modified_time(zip::DateTime::default());
let mut budget = total_cap();
for i in 0..archive.len() {
let file = archive
.by_index(i)
.map_err(|e| anyhow!("read zip entry: {e}"))?;
let name = file.name().to_string();
if name == "xl/calcChain.xml" || name == "xl/volatileDependencies.xml" {
continue;
}
if file.is_dir() {
writer
.add_directory(name, base_opts)
.map_err(|e| anyhow!("write dir entry: {e}"))?;
continue;
}
let sz = file.size();
let mut bytes = read_entry_capped(file, sz, &name, &mut budget)?;
if let Some(part_edits) = by_part.get(&name) {
bytes = rewrite_sheet(&bytes, part_edits).with_context(|| format!("rewrite {name}"))?;
}
writer
.start_file(&name, base_opts)
.map_err(|e| anyhow!("start part {name}: {e}"))?;
writer
.write_all(&bytes)
.map_err(|e| anyhow!("write part {name}: {e}"))?;
}
let cursor = writer.finish().map_err(|e| anyhow!("finalize zip: {e}"))?;
Ok(cursor.into_inner())
}
#[allow(dead_code)]
pub fn part_names(input: &[u8]) -> Result<Vec<String>> {
let mut archive =
zip::ZipArchive::new(Cursor::new(input)).map_err(|e| anyhow!("open workbook zip: {e}"))?;
let mut names = Vec::with_capacity(archive.len());
for i in 0..archive.len() {
let file = archive
.by_index(i)
.map_err(|e| anyhow!("read zip entry: {e}"))?;
names.push(file.name().to_string());
}
Ok(names)
}
#[allow(dead_code)]
pub fn sheet_part(input: &[u8], sheet_name: &str) -> Result<String> {
let workbook_xml = read_part(input, "xl/workbook.xml")?;
let rels_xml = read_part(input, "xl/_rels/workbook.xml.rels")?;
resolve_sheet_part(&workbook_xml, &rels_xml, sheet_name)
}
pub(crate) const PART_DECOMPRESS_CAP: u64 = 512 << 20; pub(crate) const TOTAL_DECOMPRESS_CAP: u64 = 2 << 30;
fn env_cap(var: &str, default: u64) -> u64 {
std::env::var(var)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(default)
}
pub(crate) fn part_cap() -> u64 {
env_cap("XLQ_MAX_PART_BYTES", PART_DECOMPRESS_CAP)
}
pub(crate) fn total_cap() -> u64 {
env_cap("XLQ_MAX_TOTAL_BYTES", TOTAL_DECOMPRESS_CAP)
}
pub(crate) fn read_entry_capped<R: Read>(
entry: R,
declared_size: u64,
name: &str,
budget: &mut u64,
) -> Result<Vec<u8>> {
let cap = part_cap().min(*budget);
let reserve = declared_size.min(cap).min(8 << 20) as usize;
let mut bytes = Vec::with_capacity(reserve);
let n = entry
.take(cap.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|e| anyhow!("read part {name}: {e}"))? as u64;
if n > cap {
bail!(
"decompression_bomb: part '{name}' exceeds the decompression cap \
({cap} bytes) — refusing (raise XLQ_MAX_PART_BYTES / XLQ_MAX_TOTAL_BYTES if intentional)"
);
}
*budget -= n;
Ok(bytes)
}
pub(crate) fn read_part(input: &[u8], name: &str) -> Result<Vec<u8>> {
let mut archive =
zip::ZipArchive::new(Cursor::new(input)).map_err(|e| anyhow!("open workbook zip: {e}"))?;
let resolved = if archive.file_names().any(|n| n == name) {
name.to_string()
} else {
match archive
.file_names()
.find(|n| n.eq_ignore_ascii_case(name))
.map(|n| n.to_string())
{
Some(n) => n,
None => return Err(anyhow!("missing part {name}")),
}
};
let file = archive
.by_name(&resolved)
.map_err(|_| anyhow!("missing part {name}"))?;
let sz = file.size();
let mut budget = total_cap();
read_entry_capped(file, sz, name, &mut budget)
}
pub(crate) fn guard_decompression(path: &str) -> Result<()> {
let f = std::fs::File::open(path).map_err(|e| anyhow!("open workbook: {e}"))?;
let mut archive = zip::ZipArchive::new(f).map_err(|e| anyhow!("open workbook zip: {e}"))?;
let mut budget = total_cap();
for i in 0..archive.len() {
let entry = archive
.by_index(i)
.map_err(|e| anyhow!("read zip entry: {e}"))?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
let cap = part_cap().min(budget);
let n = std::io::copy(&mut entry.take(cap.saturating_add(1)), &mut std::io::sink())
.map_err(|e| anyhow!("read part {name}: {e}"))?;
if n > cap {
bail!(
"decompression_bomb: part '{name}' exceeds the decompression cap \
({cap} bytes) — refusing (raise XLQ_MAX_PART_BYTES / XLQ_MAX_TOTAL_BYTES if intentional)"
);
}
budget -= n;
}
Ok(())
}
pub(crate) fn resolve_sheet_part(
workbook_xml: &[u8],
rels_xml: &[u8],
sheet_name: &str,
) -> Result<String> {
let rid = sheet_rid(workbook_xml, sheet_name)?
.ok_or_else(|| anyhow!("no sheet named {sheet_name}"))?;
let target = rid_target(rels_xml, &rid)?
.ok_or_else(|| anyhow!("no relationship {rid} for sheet {sheet_name}"))?;
Ok(resolve_target("xl", &target))
}
pub(crate) fn all_sheets(input: &[u8]) -> Result<Vec<(String, String)>> {
let workbook_xml = read_part(input, "xl/workbook.xml")?;
let rels_xml = read_part(input, "xl/_rels/workbook.xml.rels")?;
let mut reader = Reader::from_reader(workbook_xml.as_slice());
let mut buf = Vec::new();
let mut out = Vec::new();
loop {
match reader.read_event_into(&mut buf)? {
Event::Empty(e) | Event::Start(e) if local_name_eq(e.name().as_ref(), b"sheet") => {
let mut nm: Option<String> = None;
let mut rid: Option<String> = None;
for a in e.attributes().flatten() {
let key = a.key.as_ref();
if local_name_eq(key, b"name") {
nm = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.ok()
.map(|c| c.into_owned());
} else if local_name_eq(key, b"id") {
rid = Some(String::from_utf8_lossy(&a.value).into_owned());
}
}
if let (Some(n), Some(r)) = (nm, rid) {
if let Some(t) = rid_target(&rels_xml, &r)? {
out.push((n, resolve_target("xl", &t)));
}
}
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
Ok(out)
}
fn sheet_rid(workbook_xml: &[u8], name: &str) -> Result<Option<String>> {
let mut reader = Reader::from_reader(workbook_xml);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf)? {
Event::Empty(e) | Event::Start(e) if e.name().as_ref() == b"sheet" => {
let mut nm: Option<String> = None;
let mut rid: Option<String> = None;
for a in e.attributes().flatten() {
match a.key.as_ref() {
b"name" => {
nm = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.ok()
.map(|c| c.into_owned());
}
b"r:id" => {
rid = Some(String::from_utf8_lossy(&a.value).into_owned());
}
_ => {}
}
}
if nm.as_deref() == Some(name) {
return Ok(rid);
}
}
Event::Eof => return Ok(None),
_ => {}
}
buf.clear();
}
}
fn rid_target(rels_xml: &[u8], rid: &str) -> Result<Option<String>> {
let mut reader = Reader::from_reader(rels_xml);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf)? {
Event::Empty(e) | Event::Start(e) if e.name().as_ref() == b"Relationship" => {
let mut id: Option<String> = None;
let mut target: Option<String> = None;
for a in e.attributes().flatten() {
match a.key.as_ref() {
b"Id" => id = Some(String::from_utf8_lossy(&a.value).into_owned()),
b"Target" => {
target = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.ok()
.map(|c| c.into_owned());
}
_ => {}
}
}
if id.as_deref() == Some(rid) {
return Ok(target);
}
}
Event::Eof => return Ok(None),
_ => {}
}
buf.clear();
}
}
pub(crate) fn resolve_target(base_dir: &str, target: &str) -> String {
if let Some(abs) = target.strip_prefix('/') {
return abs.to_string();
}
let mut parts: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect();
for seg in target.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
struct ExistingCell {
style: Option<Vec<u8>>,
raw: Vec<u8>,
}
fn rewrite_sheet(src: &[u8], edits: &[&CellEdit]) -> Result<Vec<u8>> {
let mut edit_rows: BTreeMap<i32, BTreeMap<i32, &CellEdit>> = BTreeMap::new();
for e in edits {
edit_rows.entry(e.row).or_default().insert(e.col, e);
}
let bounds = edit_bounds(edits);
let mut reader = Reader::from_reader(src);
let mut writer = Writer::new(Vec::new());
let mut buf = Vec::new();
let mut in_sheet_data = false;
loop {
buf.clear();
let ev = reader.read_event_into(&mut buf)?;
match ev {
Event::Eof => break,
Event::Empty(e) if !in_sheet_data && e.name().as_ref() == b"dimension" => {
writer.write_event(Event::Empty(widen_dimension_tag(&e, bounds)))?;
}
Event::Start(e) if e.name().as_ref() == b"sheetData" => {
in_sheet_data = true;
writer.write_event(Event::Start(e.into_owned()))?;
}
Event::Empty(e) if e.name().as_ref() == b"sheetData" => {
writer.write_event(Event::Start(e.into_owned()))?;
while let Some((r, cells)) = edit_rows.pop_first() {
write_new_row(&mut writer, r, &cells)?;
}
writer.write_event(Event::End(BytesEnd::new("sheetData")))?;
}
Event::End(e) if e.name().as_ref() == b"sheetData" => {
while let Some((r, cells)) = edit_rows.pop_first() {
write_new_row(&mut writer, r, &cells)?;
}
in_sheet_data = false;
writer.write_event(Event::End(e.into_owned()))?;
}
Event::Start(e) if in_sheet_data && e.name().as_ref() == b"row" => {
let rnum = row_num(&e);
flush_inserts_before(&mut writer, &mut edit_rows, rnum)?;
if let Some(cells) = edit_rows.remove(&rnum) {
modify_existing_row(&mut reader, &mut writer, e.into_owned(), &cells)?;
} else {
writer.write_event(Event::Start(e.into_owned()))?;
}
}
Event::Empty(e) if in_sheet_data && e.name().as_ref() == b"row" => {
let rnum = row_num(&e);
flush_inserts_before(&mut writer, &mut edit_rows, rnum)?;
if let Some(cells) = edit_rows.remove(&rnum) {
let min_col = *cells.keys().next().unwrap();
let max_col = *cells.keys().next_back().unwrap();
writer.write_event(Event::Start(maybe_widen_row_spans(
e.into_owned(),
min_col,
max_col,
)))?;
for edit in cells.values() {
writer.get_mut().write_all(&gen_cell(edit, None))?;
}
writer.write_event(Event::End(BytesEnd::new("row")))?;
} else {
writer.write_event(Event::Empty(e.into_owned()))?;
}
}
other => {
writer.write_event(other)?;
}
}
}
if !edit_rows.is_empty() {
let missed: Vec<String> = edit_rows
.iter()
.flat_map(|(r, cols)| cols.keys().map(move |c| a1(*r, *c)))
.collect();
bail!(
"surgical edit matched no <sheetData> structure; {} edit(s) would be \
silently dropped ({}). The worksheet may use an XML namespace prefix \
xlq does not handle.",
missed.len(),
missed.join(", ")
);
}
Ok(writer.into_inner())
}
fn edit_bounds(edits: &[&CellEdit]) -> Option<(i32, i32, i32, i32)> {
let mut it = edits.iter();
let first = it.next()?;
let (mut r0, mut c0, mut r1, mut c1) = (first.row, first.col, first.row, first.col);
for e in it {
r0 = r0.min(e.row);
c0 = c0.min(e.col);
r1 = r1.max(e.row);
c1 = c1.max(e.col);
}
Some((r0, c0, r1, c1))
}
fn widen_dimension_tag(
e: &BytesStart,
bounds: Option<(i32, i32, i32, i32)>,
) -> BytesStart<'static> {
let widened = bounds.and_then(|b| {
e.attributes()
.flatten()
.find(|a| a.key.as_ref() == b"ref")
.and_then(|a| widen_ref(&a.value, b))
});
let new_ref = match widened {
Some(r) => r,
None => return e.to_owned(),
};
let mut ne = BytesStart::new("dimension");
let mut wrote_ref = false;
for a in e.attributes().flatten() {
if a.key.as_ref() == b"ref" {
ne.push_attribute(("ref", new_ref.as_str()));
wrote_ref = true;
} else {
ne.push_attribute((a.key.as_ref(), a.value.as_ref()));
}
}
if !wrote_ref {
ne.push_attribute(("ref", new_ref.as_str()));
}
ne
}
fn widen_ref(refv: &[u8], b: (i32, i32, i32, i32)) -> Option<String> {
let s = std::str::from_utf8(refv).ok()?;
let (start, end) = s.split_once(':').unwrap_or((s, s));
let (sr, sc) = parse_ref(start.as_bytes())?;
let (er, ec) = parse_ref(end.as_bytes())?;
let (r0, c0, r1, c1) = (sr.min(er), sc.min(ec), sr.max(er), sc.max(ec));
let (nr0, nc0, nr1, nc1) = (r0.min(b.0), c0.min(b.1), r1.max(b.2), c1.max(b.3));
if (nr0, nc0, nr1, nc1) == (r0, c0, r1, c1) {
return None; }
Some(format!(
"{}{}:{}{}",
col_to_letters(nc0),
nr0,
col_to_letters(nc1),
nr1
))
}
fn flush_inserts_before(
writer: &mut Writer<Vec<u8>>,
edit_rows: &mut BTreeMap<i32, BTreeMap<i32, &CellEdit>>,
rnum: i32,
) -> Result<()> {
while let Some((&r, _)) = edit_rows.first_key_value() {
if r < rnum {
let (r, cells) = edit_rows.pop_first().unwrap();
write_new_row(writer, r, &cells)?;
} else {
break;
}
}
Ok(())
}
fn modify_existing_row(
reader: &mut Reader<&[u8]>,
writer: &mut Writer<Vec<u8>>,
start: BytesStart<'static>,
cells: &BTreeMap<i32, &CellEdit>,
) -> Result<()> {
let existing = read_row_cells(reader)?;
let mut out: BTreeMap<i32, Vec<u8>> = BTreeMap::new();
for (col, ec) in &existing {
out.insert(*col, ec.raw.clone());
}
for (col, edit) in cells {
let style = existing.get(col).and_then(|e| e.style.clone());
out.insert(*col, gen_cell(edit, style.as_deref()));
}
let min_col = *out.keys().next().unwrap();
let max_col = *out.keys().next_back().unwrap();
writer.write_event(Event::Start(maybe_widen_row_spans(start, min_col, max_col)))?;
for bytes in out.values() {
writer.get_mut().write_all(bytes)?;
}
writer.write_event(Event::End(BytesEnd::new("row")))?;
Ok(())
}
fn maybe_widen_row_spans(
start: BytesStart<'static>,
min_col: i32,
max_col: i32,
) -> BytesStart<'static> {
let spans = start
.attributes()
.flatten()
.find(|a| a.key.as_ref() == b"spans")
.map(|a| a.value.into_owned());
let (lo, hi) = match &spans {
Some(v) => match parse_spans(v) {
Some((lo, hi)) if lo <= min_col && hi >= max_col => return start, Some((lo, hi)) => (lo.min(min_col), hi.max(max_col)),
None => return start, },
None => (min_col, max_col),
};
let spans_val = format!("{lo}:{hi}");
let mut ne = BytesStart::new("row");
let mut wrote = false;
for a in start.attributes().flatten() {
if a.key.as_ref() == b"spans" {
ne.push_attribute(("spans", spans_val.as_str()));
wrote = true;
} else {
ne.push_attribute((a.key.as_ref(), a.value.as_ref()));
}
}
if !wrote {
ne.push_attribute(("spans", spans_val.as_str()));
}
ne
}
fn parse_spans(v: &[u8]) -> Option<(i32, i32)> {
let s = std::str::from_utf8(v).ok()?;
let (a, b) = s.split_once(':')?;
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
}
fn read_row_cells(reader: &mut Reader<&[u8]>) -> Result<BTreeMap<i32, ExistingCell>> {
let mut cells: BTreeMap<i32, ExistingCell> = BTreeMap::new();
let mut buf = Vec::new();
let mut next_col = 1i32;
loop {
buf.clear();
match reader.read_event_into(&mut buf)? {
Event::Start(e) if e.name().as_ref() == b"c" => {
let (col_opt, style) = cell_meta(&e);
let col = col_opt.unwrap_or(next_col);
next_col = col + 1;
let mut tmp = Writer::new(Vec::new());
tmp.write_event(Event::Start(e.into_owned()))?;
let mut depth = 1i32;
let mut inner = Vec::new();
loop {
inner.clear();
let ev = reader.read_event_into(&mut inner)?;
match &ev {
Event::Start(_) => depth += 1,
Event::End(_) => depth -= 1,
Event::Eof => bail!("unterminated <c>"),
_ => {}
}
tmp.write_event(ev.into_owned())?;
if depth == 0 {
break;
}
}
cells.insert(
col,
ExistingCell {
style,
raw: tmp.into_inner(),
},
);
}
Event::Empty(e) if e.name().as_ref() == b"c" => {
let (col_opt, style) = cell_meta(&e);
let col = col_opt.unwrap_or(next_col);
next_col = col + 1;
let mut tmp = Writer::new(Vec::new());
tmp.write_event(Event::Empty(e.into_owned()))?;
cells.insert(
col,
ExistingCell {
style,
raw: tmp.into_inner(),
},
);
}
Event::End(e) if e.name().as_ref() == b"row" => break,
Event::Eof => bail!("unterminated <row>"),
_ => {}
}
}
Ok(cells)
}
fn write_new_row(
writer: &mut Writer<Vec<u8>>,
r: i32,
cells: &BTreeMap<i32, &CellEdit>,
) -> Result<()> {
let min = *cells.keys().next().unwrap();
let max = *cells.keys().next_back().unwrap();
let start = format!("<row r=\"{r}\" spans=\"{min}:{max}\">");
writer.get_mut().write_all(start.as_bytes())?;
for edit in cells.values() {
writer.get_mut().write_all(&gen_cell(edit, None))?;
}
writer.get_mut().write_all(b"</row>")?;
Ok(())
}
fn cell_meta(e: &BytesStart) -> (Option<i32>, Option<Vec<u8>>) {
let mut col = None;
let mut style = None;
for a in e.attributes().flatten() {
match a.key.as_ref() {
b"r" => {
if let Some((_, c)) = parse_ref(&a.value) {
col = Some(c);
}
}
b"s" => style = Some(a.value.into_owned()),
_ => {}
}
}
(col, style)
}
fn row_num(e: &BytesStart) -> i32 {
for a in e.attributes().flatten() {
if a.key.as_ref() == b"r" {
return std::str::from_utf8(&a.value)
.ok()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
}
}
0
}
fn gen_cell(edit: &CellEdit, style: Option<&[u8]>) -> Vec<u8> {
let r = a1(edit.row, edit.col);
let s_attr = match style {
Some(s) => format!(" s=\"{}\"", String::from_utf8_lossy(s)),
None => String::new(),
};
let out = match &edit.formula {
Some(f) => {
let ff = xml_escape_text(f.strip_prefix('=').unwrap_or(f));
match &edit.value {
CellValue::Number(n) => match fmt_num(*n) {
Some(v) => format!("<c r=\"{r}\"{s_attr}><f>{ff}</f><v>{v}</v></c>"),
None => format!("<c r=\"{r}\"{s_attr} t=\"e\"><f>{ff}</f><v>#NUM!</v></c>"),
},
CellValue::Bool(b) => format!(
"<c r=\"{r}\"{s_attr} t=\"b\"><f>{ff}</f><v>{}</v></c>",
if *b { 1 } else { 0 }
),
CellValue::Str(t) => format!(
"<c r=\"{r}\"{s_attr} t=\"str\"><f>{ff}</f><v>{}</v></c>",
xml_escape_text(t)
),
CellValue::Blank => format!("<c r=\"{r}\"{s_attr}><f>{ff}</f></c>"),
}
}
None => match &edit.value {
CellValue::Number(n) => match fmt_num(*n) {
Some(v) => format!("<c r=\"{r}\"{s_attr}><v>{v}</v></c>"),
None => format!("<c r=\"{r}\"{s_attr} t=\"e\"><v>#NUM!</v></c>"),
},
CellValue::Str(t) => format!(
"<c r=\"{r}\"{s_attr} t=\"inlineStr\"><is><t xml:space=\"preserve\">{}</t></is></c>",
xml_escape_text(t)
),
CellValue::Bool(b) => format!(
"<c r=\"{r}\"{s_attr} t=\"b\"><v>{}</v></c>",
if *b { 1 } else { 0 }
),
CellValue::Blank => format!("<c r=\"{r}\"{s_attr}/>"),
},
};
out.into_bytes()
}
fn fmt_num(n: f64) -> Option<String> {
if n.is_finite() {
Some(format!("{n}"))
} else {
None
}
}
fn xml_escape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}' => {}
_ => out.push(c),
}
}
out
}
fn a1(row: i32, col: i32) -> String {
format!("{}{}", col_to_letters(col), row)
}
fn col_to_letters(mut col: i32) -> String {
let mut s = Vec::new();
while col > 0 {
let rem = (col - 1) % 26;
s.push(b'A' + rem as u8);
col = (col - 1) / 26;
}
s.reverse();
String::from_utf8(s).unwrap()
}
fn parse_ref(r: &[u8]) -> Option<(i32, i32)> {
let mut col = 0i32;
let mut i = 0;
while i < r.len() && r[i].is_ascii_alphabetic() {
col = col * 26 + (r[i].to_ascii_uppercase() - b'A' + 1) as i32;
i += 1;
}
let digits_start = i;
let mut row = 0i32;
while i < r.len() && r[i].is_ascii_digit() {
row = row * 10 + (r[i] - b'0') as i32;
i += 1;
}
if col == 0 || i == digits_start {
return None;
}
Some((row, col))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
const MACRO: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/t1/macro.xlsm");
const PIVOT: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/t1/pivot-chart.xlsx"
);
fn read_fixture(path: &str) -> Vec<u8> {
std::fs::read(path).unwrap_or_else(|e| panic!("read fixture {path}: {e}"))
}
#[test]
fn read_entry_capped_refuses_bomb() {
let mut budget = 1024u64;
let err = read_entry_capped(std::io::repeat(0u8), 16, "bomb", &mut budget).unwrap_err();
assert!(format!("{err:#}").contains("decompression_bomb"), "{err:#}");
}
#[test]
fn read_entry_capped_reads_normal_and_debits_budget() {
let data = vec![7u8; 500];
let mut budget = 10_000u64;
let out = read_entry_capped(&data[..], 500, "part", &mut budget).unwrap();
assert_eq!(out, data, "under-cap entry read fully");
assert_eq!(budget, 10_000 - 500, "budget debited by bytes read");
}
#[test]
fn read_entry_capped_enforces_total_across_parts() {
let mut budget = 800u64;
let first = read_entry_capped(&vec![1u8; 500][..], 500, "p1", &mut budget).unwrap();
assert_eq!(first.len(), 500);
assert_eq!(budget, 300);
let err = read_entry_capped(&vec![1u8; 500][..], 500, "p2", &mut budget).unwrap_err();
assert!(
format!("{err:#}").contains("decompression_bomb"),
"total cap: {err:#}"
);
}
fn parts_map(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut map = BTreeMap::new();
for i in 0..archive.len() {
let mut f = archive.by_index(i).unwrap();
let name = f.name().to_string();
let mut b = Vec::new();
f.read_to_end(&mut b).unwrap();
map.insert(name, b);
}
map
}
fn tmp_path(tag: &str) -> String {
let dir = std::env::temp_dir().join("xlq-ooxml-tests");
std::fs::create_dir_all(&dir).unwrap();
dir.join(format!("{}-{}.xlsx", tag, std::process::id()))
.to_str()
.unwrap()
.to_owned()
}
#[test]
fn parse_and_build_refs_roundtrip() {
assert_eq!(parse_ref(b"A1"), Some((1, 1)));
assert_eq!(parse_ref(b"B7"), Some((7, 2)));
assert_eq!(parse_ref(b"AA10"), Some((10, 27)));
assert_eq!(a1(1, 1), "A1");
assert_eq!(a1(7, 2), "B7");
assert_eq!(a1(10, 27), "AA10");
assert_eq!(col_to_letters(26), "Z");
assert_eq!(col_to_letters(28), "AB");
}
#[test]
fn number_edit_preserves_all_other_parts_byte_identical() {
let input = read_fixture(MACRO);
let sheet = sheet_part(&input, "Data").unwrap();
assert_eq!(sheet, "xl/worksheets/sheet1.xml");
let edits = vec![CellEdit {
sheet: "Data".to_string(),
row: 2,
col: 2,
formula: None,
value: CellValue::Number(999.0),
}];
let out = surgical_write(&input, &edits).unwrap();
let in_parts = parts_map(&input);
let out_parts = parts_map(&out);
let in_names: BTreeSet<_> = in_parts.keys().cloned().collect();
let mut expected = in_names.clone();
expected.remove("xl/calcChain.xml");
let out_names: BTreeSet<_> = out_parts.keys().cloned().collect();
assert_eq!(out_names, expected, "part-name set drifted");
for (name, bytes) in &out_parts {
if name == &sheet {
assert_ne!(bytes, &in_parts[name], "sheet part was not rewritten");
} else {
assert_eq!(
bytes, &in_parts[name],
"non-edited part {name} not byte-identical"
);
}
}
let s = String::from_utf8_lossy(&out_parts[&sheet]);
assert!(
s.contains("<c r=\"B2\"><v>999</v></c>"),
"edit not applied: {s}"
);
}
#[test]
fn pivot_chart_parts_untouched() {
let input = read_fixture(PIVOT);
let edits = vec![CellEdit {
sheet: "Sheet1".to_string(),
row: 2,
col: 1, formula: None,
value: CellValue::Number(4242.0),
}];
let out = surgical_write(&input, &edits).unwrap();
let in_parts = parts_map(&input);
let out_parts = parts_map(&out);
for (name, bytes) in &in_parts {
if name.starts_with("xl/charts/")
|| name.starts_with("xl/pivotCache")
|| name.starts_with("xl/pivotTables")
|| name.starts_with("xl/drawings/")
{
assert_eq!(&out_parts[name], bytes, "feature part {name} was modified");
}
}
assert!(!out_parts.contains_key("xl/calcChain.xml"));
}
#[test]
fn vba_project_untouched() {
let input = read_fixture(MACRO);
let edits = vec![CellEdit {
sheet: "Data".to_string(),
row: 3,
col: 2,
formula: None,
value: CellValue::Number(7.0),
}];
let out = surgical_write(&input, &edits).unwrap();
let in_parts = parts_map(&input);
let out_parts = parts_map(&out);
assert_eq!(
out_parts["xl/vbaProject.bin"], in_parts["xl/vbaProject.bin"],
"vbaProject.bin changed"
);
}
#[test]
fn inline_string_edit_leaves_shared_strings_untouched() {
let input = read_fixture(MACRO);
let edits = vec![CellEdit {
sheet: "Data".to_string(),
row: 2,
col: 2,
formula: None,
value: CellValue::Str("hello & <world>".to_string()),
}];
let out = surgical_write(&input, &edits).unwrap();
let in_parts = parts_map(&input);
let out_parts = parts_map(&out);
assert_eq!(
out_parts["xl/sharedStrings.xml"], in_parts["xl/sharedStrings.xml"],
"sharedStrings.xml changed by an inline-string edit"
);
let sheet = String::from_utf8_lossy(&out_parts["xl/worksheets/sheet1.xml"]);
assert!(
sheet.contains(
"t=\"inlineStr\"><is><t xml:space=\"preserve\">hello & <world></t></is>"
),
"inline string not escaped/written: {sheet}"
);
}
#[test]
fn inserted_cell_and_row_reload_in_ironcalc() {
let input = read_fixture(MACRO);
let edits = vec![
CellEdit {
sheet: "Data".to_string(),
row: 2,
col: 3, formula: None,
value: CellValue::Number(555.0),
},
CellEdit {
sheet: "Data".to_string(),
row: 10,
col: 1, formula: None,
value: CellValue::Str("inserted".to_string()),
},
];
let out = surgical_write(&input, &edits).unwrap();
let path = tmp_path("insert");
std::fs::write(&path, &out).unwrap();
let model = ironcalc::import::load_from_xlsx(&path, "en", "UTC", "en")
.expect("IronCalc must re-read the surgically edited workbook");
let sheet = model
.get_worksheets_properties()
.iter()
.position(|p| p.name == "Data")
.unwrap() as u32;
assert_eq!(model.get_formatted_cell_value(sheet, 2, 3).unwrap(), "555");
assert_eq!(
model.get_formatted_cell_value(sheet, 10, 1).unwrap(),
"inserted"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn pivot_chart_reloads_after_calcchain_drop() {
let input = read_fixture(PIVOT);
let edits = vec![CellEdit {
sheet: "Sheet1".to_string(),
row: 2,
col: 1,
formula: None,
value: CellValue::Number(4242.0),
}];
let out = surgical_write(&input, &edits).unwrap();
let path = tmp_path("pivot-reload");
std::fs::write(&path, &out).unwrap();
let model = ironcalc::import::load_from_xlsx(&path, "en", "UTC", "en")
.expect("pivot+chart workbook must reload after calcChain drop");
let sheet = model
.get_worksheets_properties()
.iter()
.position(|p| p.name == "Sheet1")
.unwrap() as u32;
assert_eq!(model.get_formatted_cell_value(sheet, 2, 1).unwrap(), "4242");
let _ = std::fs::remove_file(&path);
}
#[test]
fn output_is_deterministic() {
let input = read_fixture(MACRO);
let edits = vec![CellEdit {
sheet: "Data".to_string(),
row: 2,
col: 2,
formula: None,
value: CellValue::Number(1.0),
}];
let a = surgical_write(&input, &edits).unwrap();
let b = surgical_write(&input, &edits).unwrap();
assert_eq!(a, b, "surgical_write is not reproducible");
}
#[test]
fn formula_edit_preserves_style_and_writes_cached_value() {
let input = read_fixture(PIVOT);
let edits = vec![CellEdit {
sheet: "Sheet1".to_string(),
row: 2,
col: 6, formula: Some("=A2+1".to_string()),
value: CellValue::Number(223.0),
}];
let out = surgical_write(&input, &edits).unwrap();
let out_parts = parts_map(&out);
let sheet = String::from_utf8_lossy(&out_parts["xl/worksheets/sheet1.xml"]);
assert!(
sheet.contains("<c r=\"F2\" s=\"1\"><f>A2+1</f><v>223</v></c>"),
"formula cell malformed: {sheet}"
);
}
fn edit(row: i32, col: i32, value: CellValue) -> CellEdit {
CellEdit {
sheet: "S".to_string(),
row,
col,
formula: None,
value,
}
}
fn rewrite(src: &str, edits: &[CellEdit]) -> Result<String> {
let refs: Vec<&CellEdit> = edits.iter().collect();
rewrite_sheet(src.as_bytes(), &refs).map(|b| String::from_utf8(b).unwrap())
}
#[test]
fn nonfinite_number_becomes_error_cell() {
let lit = String::from_utf8(gen_cell(
&edit(1, 1, CellValue::Number(f64::INFINITY)),
None,
))
.unwrap();
assert_eq!(lit, "<c r=\"A1\" t=\"e\"><v>#NUM!</v></c>", "got {lit}");
let nan =
String::from_utf8(gen_cell(&edit(1, 1, CellValue::Number(f64::NAN)), None)).unwrap();
assert!(nan.contains("t=\"e\"") && nan.contains("#NUM!") && !nan.contains("NaN</v>"));
let f = CellEdit {
sheet: "S".into(),
row: 1,
col: 1,
formula: Some("=1/0".into()),
value: CellValue::Number(f64::NEG_INFINITY),
};
let out = String::from_utf8(gen_cell(&f, None)).unwrap();
assert_eq!(
out, "<c r=\"A1\" t=\"e\"><f>1/0</f><v>#NUM!</v></c>",
"got {out}"
);
}
#[test]
fn illegal_control_chars_are_stripped() {
assert_eq!(xml_escape_text("bad\u{1}char\u{1f}!"), "badchar!");
assert_eq!(xml_escape_text("a\tb\nc & <d>"), "a\tb\nc & <d>");
let out = String::from_utf8(gen_cell(
&edit(1, 1, CellValue::Str("x\u{0}y".into())),
None,
))
.unwrap();
assert!(!out.contains('\u{0}'), "raw NUL leaked: {out:?}");
assert!(out.contains(">xy<"), "control char not stripped: {out}");
}
#[test]
fn positional_rless_cells_are_preserved() {
let src = "<worksheet><sheetData><row r=\"1\">\
<c><v>10</v></c><c><v>20</v></c><c><v>30</v></c></row></sheetData></worksheet>";
let out = rewrite(src, &[edit(1, 2, CellValue::Number(99.0))]).unwrap();
assert!(out.contains("<v>10</v>"), "A1 lost: {out}");
assert!(out.contains("<v>30</v>"), "C1 lost: {out}");
assert!(
out.contains("<c r=\"B1\"><v>99</v></c>"),
"B1 edit missing: {out}"
);
assert!(
!out.contains("<v>20</v>"),
"old B1 value should be replaced: {out}"
);
}
#[test]
fn prefixed_sheetdata_errors_instead_of_silent_drop() {
let src = "<x:worksheet xmlns:x=\"urn:main\"><x:sheetData><x:row r=\"1\">\
<x:c r=\"A1\"><x:v>1</x:v></x:c></x:row></x:sheetData></x:worksheet>";
let err = rewrite(src, &[edit(1, 1, CellValue::Number(42.0))]).unwrap_err();
assert!(
format!("{err}").contains("silently dropped"),
"wrong error: {err}"
);
}
#[test]
fn dimension_is_widened_to_cover_inserts() {
let src = "<worksheet><dimension ref=\"A1:B2\"/><sheetData>\
<row r=\"1\"><c r=\"A1\"><v>1</v></c></row></sheetData></worksheet>";
let out = rewrite(src, &[edit(5, 5, CellValue::Number(7.0))]).unwrap();
assert!(
out.contains("<dimension ref=\"A1:E5\"/>"),
"dimension not widened: {out}"
);
let inside = rewrite(src, &[edit(1, 1, CellValue::Number(9.0))]).unwrap();
assert!(
inside.contains("<dimension ref=\"A1:B2\"/>"),
"dimension churned: {inside}"
);
}
#[test]
fn row_spans_widened_when_cell_added_beyond() {
let src = "<worksheet><sheetData><row r=\"1\" spans=\"1:2\">\
<c r=\"A1\"><v>1</v></c><c r=\"B1\"><v>2</v></c></row></sheetData></worksheet>";
let out = rewrite(src, &[edit(1, 5, CellValue::Number(7.0))]).unwrap();
assert!(out.contains("spans=\"1:5\""), "spans not widened: {out}");
}
}