Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor cf7e632ebb address review: stop TEXT rendering past the cell limit and count UTF-16 units 2026-11-04 01:12:39 +00:00
Dylan M. Taylor 7c851d7906 numfmt: TEXT returns #VALUE! when its result passes a cell's 32,767 characters
A text section repeats its input once per @ placeholder, and TEXT handed
back whatever that built, so nested TEXT calls could multiply a long
text until memory ran out. Results past the cell text limit are now
#VALUE!.
2026-11-03 12:03:51 +00:00
6 changed files with 63 additions and 60 deletions
+3 -3
View File
@@ -755,12 +755,12 @@ thread_local! {
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a /// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
/// stored script's steps run inside one (#2178), so no command reaches the file system from a /// stored script's steps run inside one (#2178), so no command reaches the file system from a
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal. /// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
pub struct ScriptSandbox(std::marker::PhantomData<*const ()>); pub(crate) struct ScriptSandbox(());
impl ScriptSandbox { impl ScriptSandbox {
pub fn enter() -> Self { pub(crate) fn enter() -> Self {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1))); let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
ScriptSandbox(std::marker::PhantomData) ScriptSandbox(())
} }
} }
-13
View File
@@ -351,19 +351,6 @@ fn script_sandbox_refuses_file_access_until_dropped() {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
/// The guard's drop lowers the counter of the thread it runs on, so it must not move between threads:
/// naming the trait's method is ambiguous (a compile error) if `ScriptSandbox` is `Send`.
#[test]
fn script_sandbox_is_not_send() {
trait AmbiguousIfSend<A> {
fn check() {}
}
impl<T: ?Sized> AmbiguousIfSend<()> for T {}
struct IsSend;
impl<T: ?Sized + Send> AmbiguousIfSend<IsSend> for T {}
<ScriptSandbox as AmbiguousIfSend<_>>::check();
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names. /// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> { fn cfb(streams: &[&str]) -> Vec<u8> {
+15 -1
View File
@@ -332,6 +332,14 @@ impl NumberFormat {
None => Formatted { text: t.to_string(), color: None, fill: None, numeric: false }, None => Formatted { text: t.to_string(), color: None, fill: None, numeric: false },
} }
} }
/// [`Self::format_text`]'s text, or `None` once it passes `max_units` UTF-16 code units.
fn format_text_within(&self, t: &str, max_units: usize) -> Option<String> {
match self.inner.text_section.and_then(|i| self.inner.sections.get(i)) {
Some(sec) => render::render_text_within(sec, t, max_units).map(|out| out.text),
None => (t.encode_utf16().count() <= max_units).then(|| t.to_string()),
}
}
} }
/// Formats a cell value for display. /// Formats a cell value for display.
@@ -425,6 +433,10 @@ impl NumberFormat {
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is /// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text /// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
/// section, or are returned as is. /// section, or are returned as is.
/// Longest text a cell holds, in UTF-16 code units (Excel's 32,767 characters, which XLSB also
/// stores that way), and so the longest `TEXT` result.
const MAX_CELL_TEXT_CHARS: usize = 32_767;
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> { pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
if code.is_empty() { if code.is_empty() {
return Ok(String::new()); return Ok(String::new());
@@ -440,7 +452,9 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text), Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) { Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
Some(n) => n, Some(n) => n,
None => return Ok(fmt.format_text(&t).text), // Every `@` repeats the text: a result past a cell's limit is `#VALUE!`, so nested
// TEXT calls can't multiply it further.
None => return fmt.format_text_within(&t, MAX_CELL_TEXT_CHARS).ok_or(CellError::Value),
}, },
Value::Array(_) => return Err(CellError::Value), Value::Array(_) => return Err(CellError::Value),
}; };
+29
View File
@@ -45,6 +45,35 @@ pub(crate) fn render_text(sec: &Section, text: &str) -> Out {
out out
} }
/// [`render_text`], but `None` once the output passes `max_units` UTF-16 code units (a cell's
/// limit), checked before each piece is appended, so an oversized result is never built.
pub(crate) fn render_text_within(sec: &Section, text: &str, max_units: usize) -> Option<Out> {
let text_units = text.encode_utf16().count();
let mut out = Out::default();
let mut units = 0usize;
for t in &sec.toks {
match t {
Tok::At => {
units = units.saturating_add(text_units);
if units > max_units {
return None;
}
out.push(text);
}
Tok::Fill(c) => out.fill(*c),
other => {
let literal = other.literal_text();
units = units.saturating_add(literal.encode_utf16().count());
if units > max_units {
return None;
}
out.push(&literal);
}
}
}
Some(out)
}
/// Renders a number through a General section (literals around the General keyword). /// Renders a number through a General section (literals around the General keyword).
pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
let mut out = Out::default(); let mut out = Out::default();
+7
View File
@@ -396,6 +396,13 @@ fn text_fn() {
assert_eq!(text_function(&Value::Number(1234.5), "$#,##0.00", S), Ok("$1,234.50".into())); assert_eq!(text_function(&Value::Number(1234.5), "$#,##0.00", S), Ok("$1,234.50".into()));
assert_eq!(text_function(&Value::from("5"), "0.00", S), Ok("5.00".into())); assert_eq!(text_function(&Value::from("5"), "0.00", S), Ok("5.00".into()));
assert_eq!(text_function(&Value::from("abc"), "0.00", S), Ok("abc".into())); assert_eq!(text_function(&Value::from("abc"), "0.00", S), Ok("abc".into()));
// Each `@` repeats the text: up to a cell's 32,767 characters, then #VALUE!.
let at = "@".repeat(255);
assert_eq!(text_function(&Value::from("x".repeat(128).as_str()), &at, S).map(|s| s.len()), Ok(128 * 255));
assert_eq!(text_function(&Value::from("x".repeat(32_000).as_str()), &at, S), Err(CellError::Value));
// The limit counts UTF-16 units: 16,384 emoji are 32,768 of them, one too many.
assert_eq!(text_function(&Value::from("\u{1F600}".repeat(16_383).as_str()), "@", S).map(|s| s.chars().count()), Ok(16_383));
assert_eq!(text_function(&Value::from("\u{1F600}".repeat(16_384).as_str()), "@", S), Err(CellError::Value));
assert_eq!(text_function(&Value::Error(CellError::NA), "0", S), Err(CellError::NA)); assert_eq!(text_function(&Value::Error(CellError::NA), "0", S), Err(CellError::NA));
assert_eq!(text_function(&Value::Number(46302.0), "yyyy", S), Ok("2026".into())); assert_eq!(text_function(&Value::Number(46302.0), "yyyy", S), Ok("2026".into()));
assert_eq!(text_function(&Value::Number(0.5), "General", S), Ok("0.5".into())); assert_eq!(text_function(&Value::Number(0.5), "General", S), Ok("0.5".into()));
+9 -43
View File
@@ -77,8 +77,6 @@ fn probe_live_commands(calls: &[(String, String)]) -> Probe {
let params = if params.is_empty() { serde_json::json!({}) } else { serde_json::from_str(params).unwrap_or_default() }; let params = if params.is_empty() { serde_json::json!({}) } else { serde_json::from_str(params).unwrap_or_default() };
let mut s = Session::new(); let mut s = Session::new();
s.new_workbook(); s.new_workbook();
// The JSON picks the commands and their params: none of them may touch the file system.
let _sandbox = gridcraft_engine::io::ScriptSandbox::enter();
let status = match s.execute(id, params.clone()) { let status = match s.execute(id, params.clone()) {
Ok(_) => { Ok(_) => {
if s.journal.iter().any(|(jid, _)| jid == id) { if s.journal.iter().any(|(jid, _)| jid == id) {
@@ -183,16 +181,15 @@ fn parse_and_probe(source: &str) -> Result<(Vec<Entry>, Probe), String> {
} }
} }
} }
let excel = e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(); entries.push(Entry {
let note = e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(); seq,
// These are written into generated Rust: a target inside string literals and format kind,
// strings, the annotations after `//`. Refuse anything that could end either. target,
for (field, text) in [("target", &target), ("excel", &excel), ("note", &note)] { pending,
if text.chars().any(|c| c.is_control() || matches!(c, '"' | '\\' | '{' | '}')) { params,
return Err(format!("{at}: `{field}` must not hold quotes, backslashes, braces or control characters: {text:?}")); excel: e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(),
} note: e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(),
} });
entries.push(Entry { seq, kind, target, pending, params, excel, note });
} }
// JSON order is authoritative and curated: the first entry for a tab is its canonical // JSON order is authoritative and curated: the first entry for a tab is its canonical
// badge (legacy aliases come later), and groups stay together. Do not sort. // badge (legacy aliases come later), and groups stay together. Do not sort.
@@ -437,34 +434,3 @@ fn reset(h: &mut Harness) {
key(h, Key::Escape); // leave keytip mode entirely key(h, Key::Escape); // leave keytip mode entirely
} }
"#; "#;
#[cfg(all(test, feature = "engine"))]
mod tests {
use super::*;
/// Text that would end a comment or string literal in the generated Rust is refused.
#[test]
fn annotations_and_targets_cannot_escape_generated_code() {
for entry in [
r#"{"seq": "Q", "kind": "command", "target": "edit.copy", "note": "x\nfn evil() {}"}"#,
r#"{"seq": "Q", "kind": "command", "target": "edit.copy", "excel": "a\rb"}"#,
r#"{"seq": "Q", "kind": "dialog", "target": "x\"); evil(); (\""}"#,
r#"{"seq": "Q", "kind": "control", "target": "{0}"}"#,
] {
let doc = format!(r#"{{"sequences": [{entry}]}}"#);
assert!(parse_and_probe(&doc).is_err(), "{entry}");
}
assert!(parse_and_probe(r#"{"sequences": [{"seq": "Q", "kind": "control", "target": "fontSize", "note": "Font Size box"}]}"#).is_ok());
}
/// A probed command may not write files, whatever its params say.
#[test]
fn probes_cannot_touch_the_file_system() {
let path = std::env::temp_dir().join(format!("keytips-probe-{}.pdf", std::process::id()));
let _ = fs::remove_file(&path);
let calls = vec![("file.exportPdf".to_string(), serde_json::json!({ "path": path.to_string_lossy() }).to_string())];
let probe = probe_live_commands(&calls);
assert_eq!(probe.values().copied().collect::<Vec<_>>(), [2]);
assert!(!path.exists());
}
}