From 82a78e3d58eb27631f762668d7df90620bb5acf7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 00:13:46 -0700 Subject: [PATCH] Save for Web: browser previews go only to a folder the current user owns Preview in Browser wrote each preview into `VectorCraft Save for Web`, and before each preview it removed every file and folder there that was an hour old or older. Where the temporary folder is the shared /tmp, another user could make that folder first, writable by everyone, and the app wrote its previews inside it, in folders that user could read and replace. Where the system follows other users' links in /tmp, the folder could also be a link, and the app removed the old entries of the folder it named. A preview now uses that folder only when it is a folder and not a link, and on Linux, macOS and FreeBSD only when the current user owns it and no other user can write to it. On those systems the folder and each preview's folder are made with mode 0700. The cleanup removes only preview folders, on those systems only those the current user owns. When the shared folder can't be used, the preview goes to a new folder in the temporary folder. A later preview removes such folders once they are an hour old. Docs: docs/target-app-parity.md. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 6 + crates/ui-egui/src/dialogs/save_for_web.rs | 140 +++++++++++-- .../ui-egui/src/dialogs/tests_save_for_web.rs | 187 +++++++++++++++++- docs/target-app-parity.md | 2 +- 5 files changed, 317 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ffd1b3d45..3078508fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4985,6 +4985,7 @@ dependencies = [ "egui_kittest", "image", "log", + "rustix 1.1.5", "serde", "serde_json", "toml", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2ad5d60b4..73578ba02 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -38,6 +38,12 @@ log = { workspace = true } # MIT OR Apache-2.0 and already in the build through eframe. web-time = "1" +# The current user's id (`geteuid`): Preview in Browser uses its temporary folder only when that +# user owns it (src/dialogs/save_for_web.rs). Already in Cargo.lock (tempfile), so this adds no +# new crate. +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["std", "process"] } + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } vectorcraft-testkit = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 73cf74d89..6ad18da4a 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -200,30 +200,79 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result Result { - preview_folder(&std::env::temp_dir().join("VectorCraft Save for Web"), PREVIEW_KEEP) + preview_folder_in(&std::env::temp_dir(), PREVIEW_KEEP) } -/// A new folder in `root`, named after the process id and a count of the process's previews. A -/// name that is already taken, by an earlier process with the same id, is skipped for the next -/// count. Entries of `root` last changed `keep` or longer ago are removed first. +/// The name of a folder that a preview made in the temporary folder, beside a `VectorCraft Save +/// for Web` it couldn't use, before the process id and the count. +#[cfg(not(target_arch = "wasm32"))] +const BESIDE: &str = "VectorCraft Save for Web "; + +/// A new folder for a browser preview in the temporary folder `tmp`: inside `VectorCraft Save for +/// Web` ([`preview_folder`]), or, when that folder can't be used, directly in `tmp`, named +/// [`BESIDE`] followed by the process id and a count. The log says why the shared folder wasn't +/// used. Each preview first removes the folders of that name in `tmp` last changed `keep` or longer +/// ago ([`remove_old`]). +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn preview_folder_in(tmp: &std::path::Path, keep: std::time::Duration) -> Result { + remove_old(tmp, BESIDE, keep); + preview_folder(&tmp.join("VectorCraft Save for Web"), keep).or_else(|e| { + log::warn!("vectorcraft: Preview in Browser can't use {e}, so the preview goes to a new folder of its own"); + new_folder(tmp, BESIDE) + }) +} + +/// A new folder in `root` ([`new_folder`]). `root` is made when it is missing. An existing `root` +/// is refused when it is a link or not a folder, or, on unix, when it belongs to another user or +/// users other than its owner can write to it. Before the new folder is made, the preview folders +/// in `root` last changed `keep` or longer ago are removed ([`remove_old`]). #[cfg(not(target_arch = "wasm32"))] pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) -> Result { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - for e in std::fs::read_dir(root).into_iter().flatten().flatten() { - let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep); - if old { + match private_folder().recursive(true).create(root) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("{}: {e}", root.display())), + } + let m = std::fs::symlink_metadata(root).map_err(|e| format!("{}: {e}", root.display()))?; + if let Some(why) = refused(&m) { + return Err(format!("{}: {}", root.display(), why)); + } + remove_old(root, "", keep); + new_folder(root, "") +} + +/// Removes the preview folders in `dir` last changed `keep` or longer ago: folders (not links) +/// named `prefix` followed by `-`, on unix only those the current user owns. Other +/// entries are left alone. +#[cfg(not(target_arch = "wasm32"))] +fn remove_old(dir: &std::path::Path, prefix: &str, keep: std::time::Duration) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + if !is_preview_name(&e.file_name(), prefix) { + continue; + } + // `DirEntry::metadata` doesn't follow a link, so a link is never a folder here. + let Ok(m) = e.metadata() else { continue }; + let old = m.modified().ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep); + if old && m.is_dir() && mine(&m) { // Best effort: one that can't be removed now is tried again by the next preview. - let _ = if e.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(e.path()) } else { std::fs::remove_file(e.path()) }; + let _ = std::fs::remove_dir_all(e.path()); } } - std::fs::create_dir_all(root).map_err(|e| format!("{}: {e}", root.display()))?; +} + +/// A new folder in `parent` named `prefix` followed by the process id, `-` and a count of the +/// process's previews; on unix only its owner can open it. A name that is already taken, by an +/// earlier process with the same id or by anyone else, is skipped for the next count. +#[cfg(not(target_arch = "wasm32"))] +fn new_folder(parent: &std::path::Path, prefix: &str) -> Result { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); loop { - let dir = root.join(format!("{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); - match std::fs::create_dir(&dir) { + let dir = parent.join(format!("{prefix}{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + match private_folder().create(&dir) { Ok(()) => return Ok(dir.to_string_lossy().replace('\\', "/")), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => return Err(format!("{}: {e}", dir.display())), @@ -231,6 +280,69 @@ pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) } } +/// A builder for folders that only their owner can read, write or open (mode 0o700). +#[cfg(unix)] +fn private_folder() -> std::fs::DirBuilder { + let mut b = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b +} + +/// A builder for folders: a plain one outside unix. +#[cfg(all(not(unix), not(target_arch = "wasm32")))] +fn private_folder() -> std::fs::DirBuilder { + std::fs::DirBuilder::new() +} + +/// Why a preview root whose metadata (of the root itself, not what a link points to) is `m` can't +/// be used, or `None` when it can. +#[cfg(not(target_arch = "wasm32"))] +fn refused(m: &std::fs::Metadata) -> Option<&'static str> { + if m.file_type().is_symlink() { + Some("it is a link") + } else if !m.is_dir() { + Some("it is not a folder") + } else if !mine(m) { + Some("it belongs to another user") + } else if others_can_write(m) { + Some("other users can write to it") + } else { + None + } +} + +/// Whether the current user owns the file with metadata `m`. +#[cfg(unix)] +fn mine(m: &std::fs::Metadata) -> bool { + std::os::unix::fs::MetadataExt::uid(m) == rustix::process::geteuid().as_raw() +} + +/// Whether the current user owns the file with metadata `m`: always true outside unix. +#[cfg(all(not(unix), not(target_arch = "wasm32")))] +fn mine(_: &std::fs::Metadata) -> bool { + true +} + +/// Whether users other than the owner of the file with metadata `m` can write to it. +#[cfg(unix)] +fn others_can_write(m: &std::fs::Metadata) -> bool { + std::os::unix::fs::MetadataExt::mode(m) & 0o022 != 0 +} + +/// Whether users other than the owner of the file with metadata `m` can write to it: always false +/// outside unix. +#[cfg(all(not(unix), not(target_arch = "wasm32")))] +fn others_can_write(_: &std::fs::Metadata) -> bool { + false +} + +/// Whether `name` is a preview folder's name: `prefix`, then digits, `-` and digits. +#[cfg(not(target_arch = "wasm32"))] +fn is_preview_name(name: &std::ffi::OsStr, prefix: &str) -> bool { + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + name.to_str().and_then(|n| n.strip_prefix(prefix)).and_then(|n| n.split_once('-')).is_some_and(|(a, b)| digits(a) && digits(b)) +} + #[cfg(target_arch = "wasm32")] fn temp_folder() -> Result { Err("no file system here".into()) diff --git a/crates/ui-egui/src/dialogs/tests_save_for_web.rs b/crates/ui-egui/src/dialogs/tests_save_for_web.rs index d9c2d1160..465f96917 100644 --- a/crates/ui-egui/src/dialogs/tests_save_for_web.rs +++ b/crates/ui-egui/src/dialogs/tests_save_for_web.rs @@ -162,7 +162,7 @@ fn a_later_browser_preview_leaves_an_earlier_one_alone() { } } -/// Making a preview's folder removes the folders and files in its root that are `keep` old or +/// Making a preview's folder removes the preview folders in its root that are `keep` old or /// older, and keeps younger ones. #[test] fn a_browser_preview_removes_old_previews() { @@ -175,11 +175,190 @@ fn a_browser_preview_removes_old_previews() { // The folder and the page are younger than an hour, so both stay. let kept = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); assert!(std::path::Path::new(&earlier).exists() && root.join("Untitled-1.html").exists()); - // With a `keep` of zero every entry is old enough: all three go, and only the new folder is left. + // With a `keep` of zero every entry is old enough: both preview folders go, and the page, which + // no preview made, stays beside the new folder. let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap(); - assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && !root.join("Untitled-1.html").exists()); + assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && root.join("Untitled-1.html").exists()); assert!(std::path::Path::new(&next).is_dir()); - assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 2); +} + +/// Old entries of the root that aren't preview folders stay: a file, folders whose names aren't +/// `-`, and a file with a preview folder's name. +#[test] +fn a_browser_preview_removes_only_preview_folders() { + let root = vectorcraft_testkit::temp_dir("sfw-browser-filter").join("previews"); + let _ = std::fs::remove_dir_all(&root); + let earlier = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + let folders = ["notes", "12-", "-3", "12-3a", "a12-3", "12_3"]; + for name in folders { + std::fs::create_dir(root.join(name)).unwrap(); + } + let files = ["Untitled-1.html", "12-3"]; + for name in files { + std::fs::write(root.join(name), b"kept").unwrap(); + } + let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap(); + assert!(!std::path::Path::new(&earlier).exists() && std::path::Path::new(&next).is_dir()); + for name in folders { + assert!(root.join(name).is_dir(), "{name}"); + } + for name in files { + assert_eq!(std::fs::read(root.join(name)).unwrap(), b"kept", "{name}"); + } + assert_eq!(std::fs::read_dir(&root).unwrap().count(), folders.len() + files.len() + 1); +} + +/// When `VectorCraft Save for Web` in the temporary folder is a file, the preview goes to a new +/// folder of its own in the temporary folder, and the file stays as it was. +#[test] +fn a_browser_preview_goes_beside_a_root_that_is_not_a_folder() { + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-file-root"); + let root = tmp.join("VectorCraft Save for Web"); + std::fs::write(&root, b"a file").unwrap(); + let keep = std::time::Duration::ZERO; + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": it is not a folder"), "{e}"); + let dir = save_for_web::preview_folder_in(&tmp, keep).unwrap(); + let dir = std::path::Path::new(&dir); + assert!(dir.is_dir() && dir.parent() == Some(tmp.as_path()), "{}", dir.display()); + assert!(dir.file_name().unwrap().to_string_lossy().starts_with("VectorCraft Save for Web "), "{}", dir.display()); + assert_eq!(std::fs::read(&root).unwrap(), b"a file"); +} + +/// The folders that previews made beside a root they couldn't use are removed once they are +/// `keep` old, both while the root still can't be used and once it can be used again; younger +/// ones and other entries stay. +#[test] +fn a_browser_preview_removes_old_folders_made_beside_the_root() { + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-beside-old"); + let root = tmp.join("VectorCraft Save for Web"); + let _ = std::fs::remove_dir_all(&root); + std::fs::write(&root, b"a file").unwrap(); + let hour = std::time::Duration::from_secs(3600); + let first = save_for_web::preview_folder_in(&tmp, hour).unwrap(); + let second = save_for_web::preview_folder_in(&tmp, hour).unwrap(); + assert!(std::path::Path::new(&first).is_dir() && std::path::Path::new(&second).is_dir(), "{first} {second}"); + let others = ["VectorCraft Save for Web 7-7", "VectorCraft Save for Web 7-7.html", "VectorCraft Save for Web notes"]; + std::fs::write(tmp.join(others[0]), b"kept").unwrap(); + std::fs::write(tmp.join(others[1]), b"kept").unwrap(); + std::fs::create_dir_all(tmp.join(others[2])).unwrap(); + let third = save_for_web::preview_folder_in(&tmp, std::time::Duration::ZERO).unwrap(); + assert_eq!(std::path::Path::new(&third).parent(), Some(tmp.as_path()), "{third}"); + assert!(!std::path::Path::new(&first).exists() && !std::path::Path::new(&second).exists(), "{first} {second}"); + std::fs::remove_file(&root).unwrap(); + let dir = save_for_web::preview_folder_in(&tmp, std::time::Duration::ZERO).unwrap(); + assert_eq!(std::path::Path::new(&dir).parent(), Some(root.as_path()), "{dir}"); + assert!(!std::path::Path::new(&third).exists(), "{third}"); + for name in others { + assert!(tmp.join(name).exists(), "{name}"); + } +} + +/// The unix mode bits of `path`, not following a link. +#[cfg(unix)] +fn mode(path: impl AsRef) -> u32 { + std::os::unix::fs::MetadataExt::mode(&std::fs::symlink_metadata(path).unwrap()) & 0o777 +} + +/// A root and a preview folder that a preview makes are open to their owner only (0o700). +#[cfg(unix)] +#[test] +fn a_browser_preview_makes_private_folders() { + use std::os::unix::fs::PermissionsExt; + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-private"); + let root = tmp.join("previews"); + let _ = std::fs::remove_dir_all(&root); + let dir = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + assert_eq!((mode(&root), mode(&dir)), (0o700, 0o700)); + // Beside a root it can't use, too. + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); + let shared = tmp.join("VectorCraft Save for Web"); + let _ = std::fs::remove_dir_all(&shared); + std::fs::rename(&root, &shared).unwrap(); + let own = save_for_web::preview_folder_in(&tmp, std::time::Duration::from_secs(3600)).unwrap(); + assert_eq!(std::path::Path::new(&own).parent(), Some(tmp.as_path()), "{own}"); + assert_eq!(mode(&own), 0o700); +} + +/// A root that is a link is refused: nothing in the folder it points to is removed or added, and +/// the preview goes to a new folder of its own in the temporary folder. +#[cfg(unix)] +#[test] +fn a_browser_preview_refuses_a_linked_root() { + use std::os::unix::fs::symlink; + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-linked-root"); + let elsewhere = tmp.join("elsewhere"); + std::fs::create_dir_all(elsewhere.join("5-1")).unwrap(); + let root = tmp.join("VectorCraft Save for Web"); + let _ = std::fs::remove_file(&root); + symlink(&elsewhere, &root).unwrap(); + let keep = std::time::Duration::ZERO; + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": it is a link"), "{e}"); + let dir = save_for_web::preview_folder_in(&tmp, keep).unwrap(); + assert_eq!(std::path::Path::new(&dir).parent(), Some(tmp.as_path())); + let left: Vec = std::fs::read_dir(&elsewhere).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(left, ["5-1"]); +} + +/// A root that users other than its owner can write to is refused, and the old preview folder in +/// it stays. +#[cfg(unix)] +#[test] +fn a_browser_preview_refuses_a_root_others_can_write_to() { + use std::os::unix::fs::PermissionsExt; + let root = vectorcraft_testkit::temp_dir("sfw-browser-shared-root").join("previews"); + let keep = std::time::Duration::ZERO; + for bits in [0o777, 0o770, 0o703] { + let _ = std::fs::remove_dir_all(&root); + let earlier = save_for_web::preview_folder(&root, keep).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(bits)).unwrap(); + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": other users can write to it"), "{bits:o}: {e}"); + assert!(std::path::Path::new(&earlier).is_dir(), "{bits:o}"); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1, "{bits:o}"); + } +} + +/// An old link named like a preview folder stays, and so does the folder it points to, with what +/// it holds. +#[cfg(unix)] +#[test] +fn a_browser_preview_leaves_a_link_named_like_a_preview_folder() { + use std::os::unix::fs::symlink; + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-link-entry"); + let root = tmp.join("previews"); + let _ = std::fs::remove_dir_all(&root); + let outside = tmp.join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("page.html"), b"kept").unwrap(); + save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + symlink(&outside, root.join("9-9")).unwrap(); + save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap(); + assert!(std::fs::symlink_metadata(root.join("9-9")).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read(outside.join("page.html")).unwrap(), b"kept"); +} + +/// A root and a preview folder that another user owns are refused and left alone. Only root can +/// give a folder to another user, so the test skips itself for other users. +#[cfg(unix)] +#[test] +fn a_browser_preview_leaves_what_another_user_owns() { + let root = vectorcraft_testkit::temp_dir("sfw-browser-other-user").join("previews"); + let _ = std::fs::remove_dir_all(&root); + let keep = std::time::Duration::ZERO; + let theirs = save_for_web::preview_folder(&root, keep).unwrap(); + let other = std::os::unix::fs::MetadataExt::uid(&std::fs::metadata(&root).unwrap()) + 1; + if std::os::unix::fs::chown(&theirs, Some(other), None).is_err() { + eprintln!("skipped: only root can give a folder to another user"); + return; + } + save_for_web::preview_folder(&root, keep).unwrap(); + assert!(std::path::Path::new(&theirs).is_dir()); + std::os::unix::fs::chown(&root, Some(other), None).unwrap(); + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": it belongs to another user"), "{e}"); } /// A folder that an earlier process with the same id left, younger than `keep`, is not reused: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ca3afd25..5c5d50d2a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -448,7 +448,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - - Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the files and folders there that are an hour old or older (`file.saveForWeb.browser`). + - Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the preview folders there that are an hour old or older and, on Linux, macOS and FreeBSD, belong to the current user; it leaves other files and folders alone. The folders a preview makes there are open to their owner only on those systems. When `VectorCraft Save for Web` is a link or is not a folder, or on those systems belongs to another user or can be written by other users, the preview goes to a new folder of its own in the temporary folder, which a later preview removes once it is an hour old (`file.saveForWeb.browser`). - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on.