diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 16d22d4430..32d8dcb2e9 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -32,6 +32,8 @@ const MAX_COLS: usize = MAX_GRAPH_SERIES + 1; const MIN_ROWS: usize = 8; const MIN_COLS: usize = 6; const MAX_CELL: usize = 256; +/// The largest file Import Data reads, 1 MB (1,048,576 bytes), the same as Import Data Sets'. +const MAX_IMPORT_BYTES: usize = 1 << 20; /// The Cell Style limits (Adjust column width and decimal digits). const MAX_DECIMALS: u64 = 10; const MAX_WIDTH: u64 = 20; @@ -332,9 +334,16 @@ fn revert(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Text", &["txt", "tsv", "csv"])], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; - // The button is off where files can't be read. - let Some(read) = app.services.read.as_ref() else { return Ok(()) }; - let bytes = read(&path)?; + // One byte past the limit tells a larger file apart without reading all of it. + let bytes = match (&app.services.read_head, &app.services.read) { + (Some(read_head), _) => read_head(&path, MAX_IMPORT_BYTES + 1)?, + (None, Some(read)) => read(&path)?, + // The button is off where files can't be read. + (None, None) => return Ok(()), + }; + if bytes.len() > MAX_IMPORT_BYTES { + return Err("the file is larger than 1 MB".into()); + } replace_grid(d, grid_from_text(&decode(&bytes))); Ok(()) } @@ -527,6 +536,46 @@ mod tests { assert_eq!(p["series"].as_array().unwrap().len(), MAX_COLS - 1); } + /// Import Data reads a file no further than one byte past 1 MB where the host can read part of + /// a file: a larger file is refused with the sheet left as it was, and a file at the limit is + /// read. Where the host only reads whole files, a larger file is refused too. + #[test] + fn import_data_refuses_a_file_larger_than_its_limit() { + use std::{cell::RefCell, collections::HashMap, rc::Rc}; + let (mut app, _) = app_with(",a\nQ1,1", "column"); + open(&mut app).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + let at_limit = [b"\tb\nQ2\t2\n".as_slice(), &vec![b' '; MAX_IMPORT_BYTES - 8]].concat(); + let files: HashMap> = + [("/big.txt".to_string(), vec![b'1'; MAX_IMPORT_BYTES + 1]), ("/limit.txt".to_string(), at_limit)].into(); + let files = Rc::new(files); + let picked = Rc::new(RefCell::new(String::new())); + let p = picked.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(p.borrow().clone()))); + app.services.read = Some(Box::new(|_: &str| Err("the whole file was read".to_string()))); + let asked = Rc::new(RefCell::new(vec![])); + let (f, a) = (files.clone(), asked.clone()); + app.services.read_head = Some(Box::new(move |path: &str, max: usize| { + a.borrow_mut().push(max); + Ok(f.get(path).ok_or("no such file")?.iter().copied().take(max).collect()) + })); + let before = grid(&d); + *picked.borrow_mut() = "/big.txt".into(); + assert_eq!(import(&mut app, &mut d), Err("the file is larger than 1 MB".to_string())); + assert_eq!(grid(&d), before, "the sheet is left as it was"); + *picked.borrow_mut() = "/limit.txt".into(); + import(&mut app, &mut d).unwrap(); + assert_eq!((cell_at(&d, 0, 1), cell_at(&d, 1, 0)), ("b", "Q2")); + assert_eq!(*asked.borrow(), [MAX_IMPORT_BYTES + 1; 2], "each read stops one byte past the limit"); + // A host that reads only whole files. + app.services.read_head = None; + let f = files.clone(); + app.services.read = Some(Box::new(move |path: &str| Ok(f.get(path).ok_or("no such file")?.clone()))); + *picked.borrow_mut() = "/big.txt".into(); + assert_eq!(import(&mut app, &mut d), Err("the file is larger than 1 MB".to_string())); + assert_eq!(cell_at(&d, 0, 1), "b"); + } + #[test] fn transpose_switch_and_moves() { assert_eq!(transpose(&g(&[&["", "a", "b"], &["Q1", "1", "2"]])), g(&[&["", "Q1"], &["a", "1"], &["b", "2"]]));