mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7e632ebb | ||
|
|
7c851d7906 |
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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", ¬e)] {
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user