mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 12:00:01 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9f00a335 | ||
|
|
7d8c69873d |
@@ -61,6 +61,24 @@ enum Key {
|
||||
|
||||
const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000;
|
||||
|
||||
/// The cells a consolidated table writes: every row key meets every column key, plus a label for
|
||||
/// each key on the sides that have labels.
|
||||
fn output_cells(rows: usize, cols: usize, top: bool, left: bool) -> u64 {
|
||||
let (r, c) = (rows as u64, cols as u64);
|
||||
r.saturating_mul(c).saturating_add(if top { c } else { 0 }).saturating_add(if left { r } else { 0 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn the_output_limit_counts_label_cells() {
|
||||
// A million row keys and four column keys are 4 million cells, 5 million with the row labels.
|
||||
assert_eq!(output_cells(1_000_000, 4, false, false), 4_000_000);
|
||||
assert_eq!(output_cells(1_000_000, 4, false, true), 5_000_000);
|
||||
assert!(output_cells(1_000_000, 5, false, true) > MAX_CONSOLIDATE_CELLS);
|
||||
assert_eq!(output_cells(3, 2, true, true), 6 + 2 + 3);
|
||||
assert_eq!(output_cells(usize::MAX, usize::MAX, true, true), u64::MAX);
|
||||
}
|
||||
|
||||
pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.consolidate";
|
||||
let func = str_param(p, "function").unwrap_or("sum").to_string();
|
||||
@@ -89,20 +107,20 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
None => (d.wb.active_sheet(), d.selection.active),
|
||||
};
|
||||
// Keys in first-seen order, each with its index: a lookup is one probe, not a scan.
|
||||
let mut row_keys: Vec<Key> = Vec::new();
|
||||
let mut col_keys: Vec<Key> = Vec::new();
|
||||
let mut row_index: HashMap<Key, usize> = HashMap::new();
|
||||
let mut col_index: HashMap<Key, usize> = HashMap::new();
|
||||
let mut row_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut col_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new();
|
||||
let mut total: u64 = 0;
|
||||
let index_of = |keys: &mut Vec<Key>, k: Key| -> usize {
|
||||
match keys.iter().position(|x| *x == k) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
keys.push(k);
|
||||
keys.len() - 1
|
||||
}
|
||||
}
|
||||
let index_of = |keys: &mut Vec<Key>, index: &mut HashMap<Key, usize>, k: Key| -> usize {
|
||||
*index.entry(k).or_insert_with_key(|k| {
|
||||
keys.push(k.clone());
|
||||
keys.len() - 1
|
||||
})
|
||||
};
|
||||
for t in &srcs {
|
||||
let (si, r) = parse_range(&d.wb, t, CMD)?;
|
||||
@@ -131,7 +149,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
Key::Pos(col - c0)
|
||||
};
|
||||
cols.push((col, index_of(&mut col_keys, k)));
|
||||
cols.push((col, index_of(&mut col_keys, &mut col_index, k)));
|
||||
}
|
||||
for row in r0..=r.end.row {
|
||||
let k = if left {
|
||||
@@ -145,7 +163,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
Key::Pos(row - r0)
|
||||
};
|
||||
let ri = index_of(&mut row_keys, k);
|
||||
let ri = index_of(&mut row_keys, &mut row_index, k);
|
||||
for (col, ci) in &cols {
|
||||
aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col)));
|
||||
}
|
||||
@@ -157,6 +175,9 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into()));
|
||||
}
|
||||
if output_cells(row_keys.len(), col_keys.len(), top, left) > MAX_CONSOLIDATE_CELLS {
|
||||
return Err(bad(CMD, "the consolidated table would be too large"));
|
||||
}
|
||||
let mut cells: Vec<(CellRef, Value)> = Vec::new();
|
||||
if top {
|
||||
for (ci, k) in col_keys.iter().enumerate() {
|
||||
|
||||
@@ -297,3 +297,16 @@ fn consolidate_by_position_and_label() {
|
||||
assert!(s.execute("data.consolidate", json!({"sources": []})).is_err());
|
||||
assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err());
|
||||
}
|
||||
|
||||
/// A hundred thousand positional rows take one key lookup each; a tall source and a wide one
|
||||
/// whose keys would meet in a table of 160 million cells are refused before it is built.
|
||||
#[test]
|
||||
fn consolidate_keys_are_indexed_and_the_output_is_bounded() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
||||
let r = s.execute("data.consolidate", json!({"sources": ["A1:A100000"], "destination": "C1"})).unwrap();
|
||||
assert_eq!(r["rows"], 100_000);
|
||||
assert_eq!(n(&s, "Sheet1!C2"), 2.0);
|
||||
let r = s.execute("data.consolidate", json!({"sources": ["A1:A10000", "A1:XFC1"], "destination": "A1"}));
|
||||
assert!(r.unwrap_err().to_string().contains("too large"));
|
||||
}
|
||||
|
||||
@@ -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 struct ScriptSandbox(std::marker::PhantomData<*const ()>);
|
||||
pub(crate) struct 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)));
|
||||
ScriptSandbox(std::marker::PhantomData)
|
||||
ScriptSandbox(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -351,19 +351,6 @@ 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> {
|
||||
|
||||
+9
-43
@@ -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 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) {
|
||||
@@ -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();
|
||||
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", ¬e)] {
|
||||
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 });
|
||||
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(),
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
@@ -437,34 +434,3 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user