//! The user's own PDF presets, ICC profiles and plug-ins, kept in folders next to the preferences //! so that they outlive the process (#2168, #2174, #2541). The apps decide where //! ([`Session::attach_pdf_preset_store`], [`Session::attach_profile_store`], //! [`Session::attach_plugin_store`]); a session nobody attached a folder to keeps nothing, which //! is how the engine's own tests and embedders run. //! //! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes, //! so a file can also be imported by hand), named after the preset. A session that attaches the //! folder replaces its saved presets with the ones in it; a folder that doesn't exist yet takes //! the presets the session has (the ones an older version kept in the preferences file). //! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files //! named after the profile, never the path they came from (a profile in a system folder may //! move or go away). Attaching the folder registers them again. //! * **Color Settings**: the working spaces, intent and black point compensation //! `edit.colorSettings` sets, as one JSON file (#6335). Attaching it, after the profiles, makes //! them the active settings again. //! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named //! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it. //! Attaching the folder installs them again, except in Safe Mode and except the ones the user //! disabled, which `disabled.json` in the same folder lists (`plugin.setEnabled`; a window locks //! `disabled.lock` while it writes the list). The Additional Plug-ins Folder preference is not //! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins //! stored there that list permissions (id, module hash, permissions); a stored plug-in whose //! permissions it doesn't grant is skipped with a log record, and stays in the folder. //! * **Plug-in settings and caches**: `storage/id-/` in the plug-in store, the id written as //! [`case_safe`] writes it, holds what a plug-in //! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a //! `cache` folder with one file per entry. The host names every file; a plug-in only names keys. //! Replacing a plug-in keeps them, and `plugin.remove` deletes them. //! //! The files are untrusted input like any other: sizes and counts are capped, a file that does not //! parse is skipped with a log record, and every write is atomic. use std::collections::{BTreeMap, BTreeSet}; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; use vectorcraft_plugins::permissions::GrantToken; use vectorcraft_plugins::safe_mode; use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; use crate::Session; use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; /// Files read from one store folder, at most (the rest are ignored). pub const MAX_STORE_FILES: usize = 1000; /// The largest PDF presets file read (a preset is a few kilobytes). pub const MAX_PRESET_FILE: u64 = 1 << 20; /// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB). pub const MAX_PROFILE_FILE: u64 = 32 << 20; /// The most stored ICC profile bytes read at startup, all files together (the rest are skipped). pub const MAX_PROFILE_STORE: u64 = 256 << 20; /// The extension of a stored ICC profile. const PROFILE_EXT: &str = "icc"; /// The most stored plug-in bytes read at startup, all files together (the rest are skipped). pub const MAX_PLUGIN_STORE: u64 = 512 << 20; /// The extension of a stored plug-in. const PLUGIN_EXT: &str = "wasm"; /// The file in the plug-in folder that lists the plug-ins the user disabled. pub const DISABLED_FILE: &str = "disabled.json"; /// The file in the plug-in folder that a window locks while it reads and writes [`DISABLED_FILE`]. pub const DISABLED_LOCK: &str = "disabled.lock"; /// The file in the plug-in store that holds the permissions the user granted. const GRANTS_FILE: &str = "grants.json"; /// The error of a `plugin.remove` that can't remove the plug-in's grant from [`GRANTS_FILE`]. pub(crate) const GRANT_NOT_REMOVED: &str = "the plug-in's grant can't be removed from the plug-in store"; /// The largest grants file read. const MAX_GRANTS_FILE: u64 = 1 << 20; /// The folder in the plug-in store that holds each plug-in's settings and cache. const PLUGIN_DATA_DIR: &str = "storage"; /// A plug-in's settings file, in its folder. const SETTINGS_FILE: &str = "settings.json"; /// Where a settings file that doesn't read is moved, replacing the one moved there before. const BAD_SETTINGS_FILE: &str = "settings.unreadable.json"; /// The folder of a plug-in's cache entries, in its folder. const CACHE_DIR: &str = "cache"; /// The extension of a cache entry's file. const CACHE_EXT: &str = "bin"; /// Items in a cache folder a write looks at, at most: the entries, and temporary files a write left. /// A write to a folder that holds more is refused as full. pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; /// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose /// each other's settings. static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); /// The state of each plug-in folder's storage, shared by every storage made for the folder until /// the plug-in is removed, which closes it and drops it. static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// The grants of plug-in stores that the sessions of the process don't count, and when each was /// last written ([`IgnoredGrants`]). Another process reads `grants.json` as it is. static IGNORED_PLUGIN_GRANTS: Mutex = Mutex::new(IgnoredGrants { ignored: BTreeSet::new(), written: BTreeMap::new(), writes: 0 }); /// How many of the latest grant writes [`IgnoredGrants`] keeps. const MAX_GRANT_WRITES_KEPT: usize = 256; /// Grants of plug-in stores, by the store's folder ([`plugin_store_key`]) and the plug-in id. struct IgnoredGrants { /// The grants that should be gone but could not be removed: no session in the process counts /// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id /// ([`Session::store_grant`]). ignored: BTreeSet<(PathBuf, String)>, /// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the /// count of writes it made ([`IgnoredGrants::writes`]). written: BTreeMap<(PathBuf, String), u64>, /// How many grant writes the sessions of the process have made ([`Session::grant_writes`]). writes: u64, } /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name /// can't hold, or in case, can't share a file. Names equal in any case are one item. pub(crate) fn file_stem(name: &str) -> String { let safe: String = name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).take(48).collect(); // FNV-1a over the lower-cased name: stable across versions and platforms, unlike `DefaultHasher`. let hash = name.trim().to_lowercase().bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); format!("{}-{hash:016x}", safe.trim()) } /// The folder `dir` spelled one way for every path to it, so that sessions attached to it by /// different paths share [`IGNORED_PLUGIN_GRANTS`]: the canonical path when the folder exists, else /// the canonical path of its nearest folder that exists followed by the rest of the path, with `.` /// and `..` taken out by name. The path as given when it has no absolute form (the current folder /// can't be read). pub(crate) fn plugin_store_key(dir: &Path) -> PathBuf { if let Ok(path) = std::fs::canonicalize(dir) { return path; } let Ok(absolute) = std::path::absolute(dir) else { return dir.to_path_buf() }; let mut parts: Vec = Vec::new(); for part in absolute.components() { match part { std::path::Component::CurDir => {} std::path::Component::ParentDir => { if matches!(parts.last(), Some(std::path::Component::Normal(_))) { parts.pop(); } } _ => parts.push(part), } } for kept in (1..=parts.len()).rev() { let base: PathBuf = parts[..kept].iter().collect(); if let Ok(found) = std::fs::canonicalize(&base) { return parts[kept..].iter().fold(found, |path, part| path.join(part)); } } parts.iter().collect() } /// The regular files of `dir` with the extension `ext` (any case), by file name, at most /// [`MAX_STORE_FILES`]; none when the folder can't be read. fn list_files(dir: &Path, ext: &str) -> Vec { let Ok(entries) = std::fs::read_dir(dir) else { return vec![] }; let mut files: Vec = entries .filter_map(Result::ok) .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) && p.is_file()) .collect(); files.sort_by_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase())); files.truncate(MAX_STORE_FILES); files } /// The content of `path` when it is at most `max` bytes; the file is never read past `max`. pub(crate) fn read_capped(path: &Path, max: u64) -> Result, String> { let bytes = read_past_cap(path, max).map_err(|e| e.to_string())?; if bytes.len() as u64 > max { return Err(too_large(max)); } Ok(bytes) } /// The first `max + 1` bytes of `path` at most, so that a result longer than `max` shows the file is /// over its cap. fn read_past_cap(path: &Path, max: u64) -> std::io::Result> { let mut bytes = vec![]; std::fs::File::open(path)?.take(max + 1).read_to_end(&mut bytes)?; Ok(bytes) } /// What a file over its cap is told (a detail after the file's name, in the log or an error). fn too_large(max: u64) -> String { format!("larger than {max} bytes") } /// Whether `old` holds exactly `bytes`. It is read no further than one byte past their length, so a /// large file is not read into memory. pub(crate) fn holds(old: impl Read, bytes: &[u8]) -> bool { let mut read = vec![]; old.take(bytes.len() as u64 + 1).read_to_end(&mut read).is_ok() && read == bytes } /// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them /// already. A failure is logged: remembering is best effort and never fails the command. fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool { let path = dir.join(file); if std::fs::File::open(&path).is_ok_and(|old| holds(old, bytes)) { return true; } let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes)); if let Err(e) = &written { log::warn!("can't save {}: {e}", path.display()); } written.is_ok() } #[cfg(not(target_arch = "wasm32"))] fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { vectorcraft_format::write_atomic(path, bytes) } /// The browser has no folders to keep anything in (the apps there never attach a store). #[cfg(target_arch = "wasm32")] fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) } /// [`DISABLED_LOCK`] in `dir`, locked until the file is dropped, waiting while another window holds /// it; `None` when it can't be opened or locked. #[cfg(not(target_arch = "wasm32"))] fn lock_disabled(dir: &Path) -> Option { let opened = std::fs::create_dir_all(dir) .and_then(|()| std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(DISABLED_LOCK))); let file = opened.and_then(|f| f.lock().map(|()| f)); if let Err(e) = &file { log::warn!("can't lock {}: {e}", dir.join(DISABLED_LOCK).display()); } file.ok() } /// The browser has no folders to keep anything in. #[cfg(target_arch = "wasm32")] fn lock_disabled(_: &Path) -> Option { None } #[cfg(test)] thread_local! { /// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume. pub(crate) static PUT_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: removing [`GRANTS_FILE`] fails on this thread while the file still reads, as when /// another program holds it open. pub(crate) static GRANTS_DELETE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: [`Session::put_back_plugin`] writes the old module back on this thread and then /// reports a failure, as when the last flush of a copy fails. pub(crate) static PUT_BACK_FAILS_AFTER_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: moving a plug-in's settings and cache back from `.removed` ([`move_back`]) /// fails on this thread, as when another program holds a file in the folder. pub(crate) static MOVE_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: writing the `.delete` file beside a folder about to be deleted ([`delete_aside`]) /// fails on this thread, as on a full volume. pub(crate) static MARKER_WRITE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted. fn take(dir: &Path, file: &str) -> bool { let path = dir.join(file); #[cfg(test)] let removed = if file == GRANTS_FILE && GRANTS_DELETE_FAILS.get() { Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) } else { std::fs::remove_file(&path) }; #[cfg(not(test))] let removed = std::fs::remove_file(&path); match removed { Err(e) if e.kind() != std::io::ErrorKind::NotFound => { log::warn!("can't remove {}: {e}", path.display()); false } _ => true, } } impl Session { /// Keep the PDF presets this session saves in `dir`, and take the ones already there as the /// saved presets (see the module docs). Call it once, after the preferences were restored. pub fn attach_pdf_preset_store(&mut self, dir: PathBuf) { if dir.is_dir() { self.prefs.pdf_presets = load_presets(&dir); self.pdf_preset_dir = Some(dir); } else { self.pdf_preset_dir = Some(dir); for preset in self.prefs.pdf_presets.clone() { self.store_pdf_preset(None, &preset); } } } /// Write the saved preset `preset` to the store, which held it as `old_name` when that is set /// (a rename). A rename writes the new file first and removes the old one only when that write /// succeeded: after a failed write the store still holds the preset under its old name. When /// the two names differ only in case, the old file is written and then renamed to the new name; /// when that rename fails, the old file gets its content back. Without a store, nothing happens. pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) { let Some(dir) = &self.pdf_preset_dir else { return }; let mut clean = preset.clone(); clean.settings.clear_passwords(); let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name)); let bytes = match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) { Ok(bytes) => bytes, Err(e) => { log::warn!("can't save the PDF preset `{}`: {e}", preset.name); return; } }; match old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))).filter(|old| *old != file) { // Names equal in any case get file names that differ only in case. A file system that // ignores case holds them as one file, which removing the old name would delete. Some(old) if old.to_lowercase() == file.to_lowercase() => match read_capped(&dir.join(&old), MAX_PRESET_FILE) { Ok(before) => { if put(dir, &old, &bytes) && let Err(e) = std::fs::rename(dir.join(&old), dir.join(&file)) { log::warn!("can't rename {} to {file}: {e}", dir.join(&old).display()); put(dir, &old, &before); } } Err(_) => { put(dir, &file, &bytes); } }, Some(old) => { if put(dir, &file, &bytes) { take(dir, &old); } } None => { put(dir, &file, &bytes); } } } /// Remove the preset `name` from the store. pub(crate) fn unstore_pdf_preset(&self, name: &str) { if let Some(dir) = &self.pdf_preset_dir { take(dir, &format!("{}.{PRESET_FORMAT}", file_stem(name))); } } /// Keep the ICC profiles this session loads in `dir`, and register the ones already there. /// Call it before anything that names a profile (restoring the preferences, the first command). pub fn attach_profile_store(&mut self, dir: PathBuf) { let mut left = MAX_PROFILE_STORE; for path in list_files(&dir, PROFILE_EXT) { if left == 0 { log::warn!("skipping the other stored colour profiles: they hold more than {MAX_PROFILE_STORE} bytes together"); break; } let loaded = read_capped(&path, MAX_PROFILE_FILE.min(left)).and_then(|bytes| { left -= bytes.len() as u64; vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string()) }); if let Err(e) = loaded { log::warn!("skipping the stored colour profile {}: {e}", path.display()); } } self.profile_dir = Some(dir); } /// Write the profile `name` (its file's `bytes`) to the store; `false` when it didn't go there. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn store_profile(&self, name: &str, bytes: &[u8]) -> bool { self.profile_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)), bytes)) } /// Remove the profile `name` from the store. pub(crate) fn unstore_profile(&self, name: &str) { if let Some(dir) = &self.profile_dir { take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name))); } } /// Keep Edit › Color Settings (the working spaces, intent and black point compensation) in the /// file `path`, and make the settings saved there the active ones (#6335). After /// [`Session::attach_profile_store`]: they may name a profile it registers. Settings that can't be /// read, or that name a profile that is gone, leave the defaults with a log record, and the file /// as it is until the settings change. pub fn attach_color_settings(&mut self, path: PathBuf) { if path.exists() { let restored = read_capped(&path, MAX_PRESET_FILE) .and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| e.to_string())) .and_then(|settings| vectorcraft_color::cms::set_active(&settings).map_err(|e| e.to_string())); if let Err(e) = restored { log::warn!("not using the saved color settings {}: {e}", path.display()); } } self.color_settings_file = Some(path); } /// Write `settings` to the file [`Session::attach_color_settings`] named; `false` when they /// didn't go there. pub(crate) fn store_color_settings(&self, settings: &vectorcraft_color::cms::ColorSettings) -> bool { let Some(path) = &self.color_settings_file else { return false }; let (Some(dir), Some(file)) = (path.parent(), path.file_name().and_then(|f| f.to_str())) else { return false }; serde_json::to_vec_pretty(settings).is_ok_and(|bytes| put(dir, file, &bytes)) } /// Keep the plug-ins this session installs in `dir`, take the disabled plug-ins saved there /// ([`DISABLED_FILE`]), and install the stored ones ([`Session::load_stored_plugins`]). pub fn attach_plugin_store(&mut self, dir: PathBuf) { let disabled = read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); safe_mode::set_disabled(disabled); self.plugin_dir = Some(dir); self.load_stored_plugins(); } /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user /// disabled (matched by file name, which is made from the id). The launch marker names each /// file while it loads. A module that doesn't load, and one that lists permissions the grants /// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and /// stays in the folder with its grant. A grant for an id whose stored module lists no /// permissions is forgotten; one that can't be removed from the grants file doesn't count for /// any session in the process attached to the folder until the file can be written for the id /// ([`Session::ignore_stored_grant`]). pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { return; } // A `plugin.install` in another session writes the store before this reads it. let _held = crate::cmd::plugin::hold_plugin_changes(); let grants = self.counted_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); let mut left = MAX_PLUGIN_STORE; let mut asks_nothing = BTreeSet::new(); for path in list_files(&dir, PLUGIN_EXT) { if left == 0 { log::warn!("skipping the other stored plug-ins: they hold more than {MAX_PLUGIN_STORE} bytes together"); break; } let item = safe_mode::Item::file(path.display().to_string()); let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()); let by_id = safe_mode::skipped_ids().iter().any(|id| stem.as_deref() == Some(file_stem(id).as_str())); if safe_mode::skipped(&item) || by_id { log::info!("skipping the stored plug-in {}: disabled", path.display()); continue; } let token = safe_mode::loading(&item); let loaded = read_capped(&path, (limits.max_module_bytes as u64).min(left)).and_then(|bytes| { left -= bytes.len() as u64; vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string()) }); safe_mode::loaded(token); match loaded { // Only this store's grants count: one given elsewhere in the process doesn't let a // module stored here load. Ok(plugin) if plugin.manifest().asks_permissions() => { let m = plugin.manifest(); match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { asks_nothing.remove(&m.id); vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { log::warn!("skipping the stored plug-in {}: {e}", path.display()); } } None => { log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) } } } Ok(plugin) => { asks_nothing.insert(plugin.id().to_string()); vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { log::warn!("skipping the stored plug-in {}: {e}", path.display()); } } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), } } let kept = read_grants(&dir); for id in &asks_nothing { vectorcraft_plugins::registry::revoke(id); let named = match &kept { Ok(file) => file.names(id), Err(e) => e.unread, }; if named { let writes = self.grant_writes(); if !self.store_grant(id, None) && self.holds_plugin_grant(id) { self.ignore_stored_grant(id, writes); } } } } /// Write the disabled plug-ins saved for the next launch to the plug-in folder → whether they /// went there, every change included (`false` without a folder, and when a plug-in turned off /// found the file's list full: [`safe_mode::Merged::complete`]). The changes this launch made are applied to the list /// the file holds now ([`safe_mode::merge_saved`]), so a change another window saved to the same /// folder meanwhile stays. Windows that save at the same time take turns on [`DISABLED_LOCK`]; /// when it can't be locked, nothing is written, and the next change saves this one too. So is /// a file that can't be read (too large, say) or that a later version wrote /// ([`safe_mode::read_disabled_file`]): it is kept as it is. pub fn store_disabled_plugins(&self) -> bool { let Some(dir) = self.plugin_dir.as_deref() else { return false }; let Some(_turn) = lock_disabled(dir) else { return false }; let path = dir.join(DISABLED_FILE); let theirs = match read_capped(&path, safe_mode::MAX_DISABLED_FILE_BYTES) { Ok(bytes) => match safe_mode::read_disabled_file(&bytes) { Some(items) => items, None => { log::warn!("can't save {}: it holds another format or version of the list", path.display()); return false; } }, Err(_) if !path.exists() => Vec::new(), Err(e) => { log::warn!("can't save {}: {e}", path.display()); return false; } }; let merged = safe_mode::merge_saved(Some(theirs)); let written = put(dir, DISABLED_FILE, &merged.bytes); if written { safe_mode::saved_written(&merged); } written && merged.complete() } /// Whether `plugin` installs without asking the user: it lists no permissions, or this very /// module (its id and SHA-256) was granted every one it lists, in the grants of the attached /// plug-in store when there is one ([`grant_covers`]), not counting a grant that should be gone /// and could not be removed ([`Session::ignore_stored_grant`]), else in this process's /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) } /// [`Session::plugin_approved`] for many plug-ins: reads the store's grants once, when it is /// made, and checks each plug-in against them. pub(crate) fn plugin_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir)); move |plugin| { let m = plugin.manifest(); match &grants { Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| grant_covers(g, plugin)), _ => vectorcraft_plugins::registry::approved(plugin), } } } /// [`Session::plugin_approver`] for a folder load: a module the store's grants approve gets /// that grant recorded for the process too, since the registry installs only a module that a /// recorded grant covers ([`vectorcraft_plugins::registry::install`]). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn plugin_folder_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir)); move |plugin| { let m = plugin.manifest(); match &grants { Some(grants) if m.asks_permissions() => match grants.get(&m.id).filter(|g| grant_covers(g, plugin)) { Some(g) => { vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); true } None => false, }, _ => vectorcraft_plugins::registry::approved(plugin), } } } /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those the process /// doesn't count for that folder ([`Session::ignore_stored_grant`]). The folder's key is worked /// out on each call, so a path whose link now leads elsewhere is matched with where it leads. fn counted_grants(&self, dir: &Path) -> BTreeMap { let key = plugin_store_key(dir); let mut grants = stored_grants(dir); let shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); for (_, id) in shared.ignored.range((key.clone(), String::new())..).take_while(|(folder, _)| *folder == key) { grants.remove(id); } grants } /// Write the module `bytes` of the plug-in `id` to the store → whether they went there (`false` /// without a folder). pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) -> bool { self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) } /// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and /// an error, with a log record, when the file can't be read or is larger than the module limit. pub(crate) fn stored_plugin_bytes(&self, id: &str) -> Result>, String> { let Some(dir) = self.plugin_dir.as_deref() else { return Ok(None) }; let path = dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))); if !path.exists() { return Ok(None); } let max = vectorcraft_plugins::Limits::default().max_module_bytes as u64; read_capped(&path, max).map(Some).inspect_err(|e| log::warn!("can't read the stored plug-in {}: {e}", path.display())) } /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. → whether the /// write reported success; a write that reports a failure may still have put `previous` back. pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool { #[cfg(test)] if PUT_BACK_FAILS.get() { return false; } let back = match previous { Some(bytes) => self.store_plugin(id, bytes), None => self.unstore_plugin(id), }; #[cfg(test)] if PUT_BACK_FAILS_AFTER_WRITE.get() { return false; } back } /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it /// (`None` without a plug-in folder). pub(crate) fn stored_plugin_file(&self, id: &str) -> Option { self.plugin_dir.as_ref().map(|dir| dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))).display().to_string()) } /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's /// other grants as they are; with none left, remove the file. Entries this version can't use /// are written back as they were, except one for `id`. A grants file that is too large, can't /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now /// holds what was asked (`false` without a folder). When it does, the grant kept for `id` counts /// again, for every session in the process attached to the store ([`Session::ignore_stored_grant`]). pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { let done = self.write_grant(id, grant); if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); let entry = (key, id.to_string()); shared.ignored.remove(&entry); shared.writes = shared.writes.saturating_add(1); let writes = shared.writes; shared.written.insert(entry, writes); if shared.written.len() > MAX_GRANT_WRITES_KEPT && let Some(oldest) = shared.written.iter().min_by_key(|(_, at)| **at).map(|(entry, _)| entry.clone()) { shared.written.remove(&oldest); } } done } /// How many grant writes the sessions of the process have made ([`Session::store_grant`]). /// Taken before a write that may fail, for [`Session::ignore_stored_grant`]. pub(crate) fn grant_writes(&self) -> u64 { IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes } /// How many grant writes the process keeps ([`IgnoredGrants::written`]). #[cfg(test)] pub(crate) fn grant_writes_kept() -> usize { IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).written.len() } /// Stop counting the attached store's grant for the plug-in `id`, in every session in the /// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in /// one of them: for a grant that should be gone and could not be removed, which would otherwise /// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed /// write: when a session has written the store's grants for `id` since, the file holds what that /// write asked for, and the grant keeps counting. A write no longer among the latest /// [`MAX_GRANT_WRITES_KEPT`] is not seen, and the grant stops counting. Another process on the /// folder reads `grants.json` as it is. pub(crate) fn ignore_stored_grant(&self, id: &str, writes: u64) { if let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); let entry = (key, id.to_string()); if shared.written.get(&entry).is_none_or(|at| *at <= writes) { shared.ignored.insert(entry); } } } fn write_grant(&self, id: &str, grant: Option) -> bool { let Some(dir) = &self.plugin_dir else { return false }; let mut file = match read_grants(dir) { Ok(file) => file, Err(e) => { log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), e.why); return false; } }; let others = file.other.len(); file.other.retain(|entry| entry.get("id").and_then(serde_json::Value::as_str) != Some(id)); let changed = match grant { Some(g) => file.grants.insert(id.to_string(), g.clone()).as_ref() != Some(&g), None => file.grants.remove(id).is_some(), } || file.other.len() != others; if !changed { return true; } let mut entries: Vec = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect(); entries.extend(file.other); if entries.is_empty() { return take(dir, GRANTS_FILE); } // The file is read back only up to its cap: written compactly when pretty-printed is larger, // and not at all when that is too. let fits = |bytes: &Vec| bytes.len() as u64 <= MAX_GRANTS_FILE; match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) { Some(bytes) => put(dir, GRANTS_FILE, &bytes), None => { log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)); false } } } /// Whether the store holds, or may hold, an entry for the plug-in `id`, including one this /// version can't use ([`GrantsFile::names`]). `true` when the grants file can't be opened or /// read, because it may name `id` once it reads again; `false` without a folder, and when the /// file is too large or doesn't hold a list. pub(crate) fn holds_plugin_grant(&self, id: &str) -> bool { self.plugin_dir.as_deref().is_some_and(|dir| match read_grants(dir) { Ok(file) => file.names(id), Err(e) => e.unread, }) } /// Remove the plug-in `id` from the store → whether its file is gone (`false` without a folder). pub(crate) fn unstore_plugin(&self, id: &str) -> bool { self.plugin_dir.as_deref().is_some_and(|dir| take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)))) } /// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without /// one, nowhere (its storage calls then fail as unavailable). Settings and cache that a removal /// kept are moved back first ([`plugin_storage`]). pub(crate) fn attach_plugin_storage(&self, id: &str) { vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id)); } /// Does the attached plug-in store have a folder of settings and cache for plug-in `id`, or one /// that a removal kept ([`aside_paths`], [`is_kept`])? A folder behind a `storage` folder /// that is a link is not counted: the plug-in's storage calls can't reach it and removing the /// plug-in leaves it. pub(crate) fn has_plugin_storage(&self, id: &str) -> bool { self.plugin_dir.as_deref().is_some_and(|dir| { let path = plugin_data_dir(dir, id); !is_link(&dir.join(PLUGIN_DATA_DIR)) && (std::fs::symlink_metadata(&path).is_ok() || aside_paths(&path).iter().any(|a| is_kept(a))) }) } /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. /// Settings and cache that an earlier removal kept ([`is_kept`]) are first moved back when the /// folder is missing ([`restore_removed`]); what earlier deletions left is deleted. The folder is /// then renamed out of the way ([`aside_paths`]: `.removed`, or `.removing` while /// `.removed` holds kept settings), and that fails, leaving it as it was, when the rename /// does. `forget` runs once the folder is out of the way, before anything is deleted: when it /// returns `false`, the folder is renamed back and this fails, and the kept settings stay. When /// that rename fails too, the error also names both paths and the reason, and the next attach or /// removal for the id moves the folder back. Once `forget` returns `true`, the folder and the /// kept settings are deleted ([`delete_aside`]); what that deletion leaves stays, with a log /// record, and a later call for the id tries to delete it again. pub(crate) fn remove_plugin_storage(&self, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> { let not_forgotten = || StorageError::Io(GRANT_NOT_REMOVED.to_string()); vectorcraft_plugins::registry::set_storage(id, None); let Some(dir) = &self.plugin_dir else { return if forget() { Ok(()) } else { Err(not_forgotten()) }; }; let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let path = plugin_data_dir(dir, id); // Every storage handed out for the folder, including those runs still hold, is closed once // the folder is out of the way; until then they keep working. let close = || { if let Some(data) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { data.closed.store(true, Ordering::Release); } }; // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link // in place of the plug-in's own folder is removed itself (`remove_dir_all` doesn't follow it). let data = dir.join(PLUGIN_DATA_DIR); if is_link(&data) { log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display()); if !forget() { return Err(not_forgotten()); } close(); return Ok(()); } let asides = aside_paths(&path); if std::fs::symlink_metadata(&path).is_err() && let Err(e) = restore_removed(&path) { log::warn!("can't move the settings and cache of {} back: {e}", path.display()); } for aside in asides.iter().filter(|a| !is_kept(a)) { delete_aside(aside); } let target = if std::fs::symlink_metadata(&path).is_err() { asides.iter().rev().find(|a| is_kept(a)).unwrap_or(&asides[0]) } else if !is_kept(&asides[0]) { &asides[0] } else if std::fs::symlink_metadata(&asides[1]).is_err() { &asides[1] } else { return Err(StorageError::Io(format!("{}: {} and {} are in the way", path.display(), asides[0].display(), asides[1].display()))); }; match std::fs::rename(&path, target) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))), Ok(()) => {} } if !forget() { if std::fs::symlink_metadata(target).is_ok() && let Err(e) = move_back(target, &path) { log::warn!("can't move {} back: {e}", target.display()); return Err(StorageError::Io(format!( "{GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {} to {}: {e}", target.display(), path.display() ))); } return Err(not_forgotten()); } close(); for aside in &asides { delete_aside(aside); } Ok(()) } } /// `name` (a plug-in id or a cache entry name, ASCII) written for a file name that no other name /// gets on a file system that ignores case: `_` is written `__`, each capital letter `_` and the /// letter in lower case, and `.` `_0`. fn case_safe(name: &str) -> String { let mut file = String::with_capacity(name.len() * 2); for c in name.chars() { match c { '_' => file.push_str("__"), '.' => file.push_str("_0"), c if c.is_ascii_uppercase() => { file.push('_'); file.push(c.to_ascii_lowercase()); } c => file.push(c), } } file } /// The folder of plug-in `id`'s settings and cache in the plug-in store `dir`. The `id-` in front /// keeps an id such as `con` from naming a device on Windows. pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id))) } /// `path` with `suffix` added to its last part. fn suffixed(path: &Path, suffix: &str) -> PathBuf { let mut named = path.as_os_str().to_os_string(); named.push(suffix); PathBuf::from(named) } /// Where [`Session::remove_plugin_storage`] moves the plug-in folder `path` out of the way: /// `.removed`, and `.removing` while `.removed` holds settings and cache an /// earlier removal kept ([`is_kept`]). The second one, when it is there, is the newer. fn aside_paths(path: &Path) -> [PathBuf; 2] { [suffixed(path, ".removed"), suffixed(path, ".removing")] } /// `