Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor dedb31e76d address review: make ScriptSandbox !Send so it drops on the thread that entered it 2026-11-04 01:34:12 +00:00
Dylan M. Taylor 9664c01289 xtask: probe keytip commands without file access, and refuse text that could escape generated code
cargo xtask keytips ran every command keytips.json names, with its
params, under the user's file permissions (file.exportPdf could replace
any file, also during --check). It also wrote target, excel and note
strings into generated Rust without escaping. Probes now run inside the
engine's ScriptSandbox, and those fields may not hold quotes,
backslashes, braces or control characters.
2026-11-03 13:16:14 +00:00
6 changed files with 71 additions and 89 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
/// 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.
pub(crate) struct ScriptSandbox(());
pub struct ScriptSandbox(std::marker::PhantomData<*const ()>);
impl ScriptSandbox {
pub(crate) fn enter() -> Self {
pub fn enter() -> Self {
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
ScriptSandbox(())
ScriptSandbox(std::marker::PhantomData)
}
}
+13
View File
@@ -351,6 +351,19 @@ fn script_sandbox_refuses_file_access_until_dropped() {
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
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
+12 -25
View File
@@ -59,14 +59,11 @@ fn bool_prop(e: &El, name: &str) -> bool {
const DEFAULT_FONT_FAMILY: u8 = 2;
const MAX_FONT_FAMILY: u8 = 14;
/// Longest font name kept, in characters (Excel's own limit). Every cell format copies its
/// font, so a long name repeated by many formats would otherwise multiply in memory.
const MAX_FONT_NAME: usize = 31;
pub fn read_font(e: &El, palette: &[u32]) -> Font {
let mut f = Font::default();
if let Some(n) = e.child("name").or_else(|| e.child("rFont")).and_then(|n| n.attr("val")) {
f.name = n.chars().take(MAX_FONT_NAME).collect();
f.name = n.to_string();
}
if let Some(sz) = e.child("sz").and_then(|s| s.attr_f64("val")) {
f.size = sz.clamp(1.0, 409.0) as f32;
@@ -280,14 +277,14 @@ fn read_protection(p: &El) -> Protection {
Protection { locked: p.flag("locked", true), hidden: p.flag("hidden", false) }
}
fn num_fmt_code(id: u32, custom: &HashMap<u32, NumFmt>) -> NumFmt {
fn num_fmt_code(id: u32, custom: &HashMap<u32, String>) -> NumFmt {
if let Some(c) = custom.get(&id) {
return c.clone();
return NumFmt::new(c);
}
NumFmt::new(builtin_format(id).unwrap_or("General"))
}
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, NumFmt>, checkboxes: &[bool]) -> Style {
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, String>, checkboxes: &[bool]) -> Style {
let mut s = Style::default();
if let Some(f) = xf.attr_u32("fontId").and_then(|i| fonts.get(i as usize)) {
s.font = f.clone();
@@ -339,8 +336,6 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
}
}
}
// One shared copy of each custom code, which every format using it points to.
let shared_fmts: HashMap<u32, NumFmt> = fmts.iter().map(|(id, code)| (*id, NumFmt::new(code))).collect();
// The scheme link only means something on a font in the body face; elsewhere it stays as a new font has it.
let body_font = |e: &El| {
let mut f = read_font(e, &palette);
@@ -350,14 +345,10 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
let fonts: Vec<Font> = root.child("fonts").map(|f| f.kids("font").map(body_font).collect()).unwrap_or_default();
let fills: Vec<Fill> = root.child("fills").map(|f| f.kids("fill").map(|e| read_fill(e, &palette, false)).collect()).unwrap_or_default();
let borders: Vec<Borders> = root.child("borders").map(|f| f.kids("border").map(|e| read_border(e, &palette)).collect()).unwrap_or_default();
let xfs = root
.child("cellXfs")
.map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &shared_fmts, checkboxes)).collect())
.unwrap_or_default();
let style_xfs: Vec<Style> = root
.child("cellStyleXfs")
.map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &shared_fmts, &[])).collect())
.unwrap_or_default();
let xfs =
root.child("cellXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, checkboxes)).collect()).unwrap_or_default();
let style_xfs: Vec<Style> =
root.child("cellStyleXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, &[])).collect()).unwrap_or_default();
let mut named = Vec::new();
if let Some(cs) = root.child("cellStyles") {
for c in cs.kids("cellStyle") {
@@ -368,14 +359,12 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
}
}
}
// Inline dxf format codes are interned: a code repeated by many dxfs is one allocation.
let mut inline_fmts = HashMap::new();
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &shared_fmts, &mut inline_fmts)).collect()).unwrap_or_default();
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &fmts)).collect()).unwrap_or_default();
StylesIn { xfs, dxfs, palette, named, num_fmts: fmts }
}
/// One differential format (`dxf`): the style it sets over a cell's own.
fn read_dxf<'a>(x: &'a El, palette: &[u32], fmts: &HashMap<u32, NumFmt>, inline_fmts: &mut HashMap<&'a str, NumFmt>) -> Style {
fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap<u32, String>) -> Style {
let mut s = Style::default();
if let Some(e) = x.child("font") {
let f = read_font(e, palette);
@@ -398,7 +387,7 @@ fn read_dxf<'a>(x: &'a El, palette: &[u32], fmts: &HashMap<u32, NumFmt>, inline_
}
if let Some(n) = x.child("numFmt") {
if let Some(code) = n.attr("formatCode") {
s.num_fmt = inline_fmts.entry(code).or_insert_with(|| NumFmt::new(code)).clone();
s.num_fmt = NumFmt::new(code);
} else if let Some(id) = n.attr_u32("numFmtId") {
s.num_fmt = num_fmt_code(id, fmts);
}
@@ -768,12 +757,10 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
s.push_str("</numFmts>");
}
let _ = write!(s, "<fonts count=\"{}\">", fonts.len());
// Font names are cut to `MAX_FONT_NAME` on reading: so is the theme's, to compare with them.
let theme_minor: String = wb.theme.minor_font.chars().take(MAX_FONT_NAME).collect();
for (i, f) in fonts.iter().enumerate() {
// A Normal font in the theme's body face follows the theme, as Excel writes it (#805); so
// does every other body-face font that kept the scheme link (#2517).
s.push_str(&font_xml(f, None, (i == 0 || f.minor) && f.name == theme_minor));
s.push_str(&font_xml(f, None, (i == 0 || f.minor) && f.name == wb.theme.minor_font));
}
let _ = write!(s, "</fonts><fills count=\"{}\">", fills.len());
for f in &fills {
-33
View File
@@ -737,36 +737,3 @@ fn every_skipped_drawing_object_is_reported() {
assert_eq!(hits, 1, "object {n}: {:?}", rep.warnings);
}
}
/// A theme body font longer than 31 characters (the length font names are cut to on reading)
/// still links a body-face font to the theme when saving.
#[test]
fn a_font_cut_to_31_characters_keeps_the_scheme_link_to_a_longer_theme_font() {
let long: String = "B".repeat(40);
let mut wb = gridcraft_model::Workbook::default();
wb.theme.minor_font = long.clone();
let style = wb.styles.derive(StyleId::DEFAULT, |s| {
s.font.name = long.chars().take(31).collect();
s.font.minor = true;
});
let mut sheet = gridcraft_model::Sheet::new("S");
sheet.cells.set(at("A1"), gridcraft_model::Cell { value: Value::Number(1.0), formula: None, style, py: None });
wb.sheets.push(std::sync::Arc::new(sheet));
let out = crate::write_xlsx(&wb).unwrap();
let st = String::from_utf8(crate::package::Package::open(&out).unwrap().read("xl/styles.xml").unwrap().unwrap()).unwrap();
assert!(st.contains(r#"<scheme val="minor"/>"#), "{st}");
}
/// A number format code repeated by many differential formats is read into one allocation.
#[test]
fn inline_dxf_number_formats_are_shared() {
let code = format!("0.00{}", " \"x\"".repeat(50));
let dxfs: String = (0..3).map(|_| format!(r#"<dxf><numFmt numFmtId="170" formatCode="{}"/></dxf>"#, code.replace('"', "&quot;"))).collect();
let styles =
format!(r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><dxfs count="3">{dxfs}</dxfs></styleSheet>"#);
let root = crate::xml::parse(styles.as_bytes()).unwrap();
let read = crate::styles::read_styles(&root, &[], "Calibri");
assert_eq!(read.dxfs.len(), 3);
assert_eq!(read.dxfs[0].num_fmt.as_str(), code);
assert!(read.dxfs.iter().all(|d| std::sync::Arc::ptr_eq(&d.num_fmt.0, &read.dxfs[0].num_fmt.0)));
}
-19
View File
@@ -385,22 +385,3 @@ fn outline_summary_directions_round_trip() {
let xml = String::from_utf8(crate::package::Package::open(&bytes).unwrap().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
assert!(!xml.contains("outlinePr"), "{xml}");
}
/// Many cell formats naming one long font and one long custom number format: the code is shared
/// by every format and the font name is cut to Excel's 31 characters, so neither is copied per
/// format.
#[test]
fn cell_formats_share_their_number_format_and_keep_short_font_names() {
let xml = format!(
r#"<styleSheet><numFmts><numFmt numFmtId="164" formatCode="{}"/></numFmts><fonts><font><name val="{}"/></font></fonts><cellXfs>{}</cellXfs></styleSheet>"#,
"0".repeat(100_000),
"F".repeat(100_000),
r#"<xf numFmtId="164" fontId="0"/>"#.repeat(20_000)
);
let root = crate::xml::parse(xml.as_bytes()).unwrap();
let st = crate::styles::read_styles(&root, &[], "Calibri");
assert_eq!(st.xfs.len(), 20_000);
assert!(st.xfs.iter().all(|s| std::sync::Arc::ptr_eq(&s.num_fmt.0, &st.xfs[0].num_fmt.0)));
assert_eq!(st.xfs[0].num_fmt.as_str().len(), 100_000);
assert!(st.xfs.iter().all(|s| s.font.name.chars().count() == 31));
}
+43 -9
View File
@@ -77,6 +77,8 @@ 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 mut s = Session::new();
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()) {
Ok(_) => {
if s.journal.iter().any(|(jid, _)| jid == id) {
@@ -181,15 +183,16 @@ fn parse_and_probe(source: &str) -> Result<(Vec<Entry>, Probe), String> {
}
}
}
entries.push(Entry {
seq,
kind,
target,
pending,
params,
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(),
});
let excel = e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string();
let note = e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string();
// These are written into generated Rust: a target inside string literals and format
// strings, the annotations after `//`. Refuse anything that could end either.
for (field, text) in [("target", &target), ("excel", &excel), ("note", &note)] {
if text.chars().any(|c| c.is_control() || matches!(c, '"' | '\\' | '{' | '}')) {
return Err(format!("{at}: `{field}` must not hold quotes, backslashes, braces or control characters: {text:?}"));
}
}
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
// badge (legacy aliases come later), and groups stay together. Do not sort.
@@ -434,3 +437,34 @@ fn reset(h: &mut Harness) {
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());
}
}