Files
vectorcraft/crates/engine/src/store.rs
T

946 lines
44 KiB
Rust

//! 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.
//! * **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-<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;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use vectorcraft_pdf::{PdfPreset, builtin_preset};
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 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 closed flag of each plug-in folder's storage, shared by every storage made for the folder
/// until the plug-in is removed, which sets it and drops it.
static PLUGIN_DATA_OPEN: Mutex<BTreeMap<PathBuf, Arc<AtomicBool>>> = Mutex::new(BTreeMap::new());
/// 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 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<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return vec![] };
let mut files: Vec<PathBuf> = 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<Vec<u8>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut bytes = vec![];
file.take(max + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
if bytes.len() as u64 > max {
return Err(too_large(max));
}
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")
}
/// 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::read(&path).is_ok_and(|old| 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<std::fs::File> {
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<std::fs::File> {
None
}
/// Remove `dir/file`; a file that isn't there is what was wanted.
fn take(dir: &Path, file: &str) {
let path = dir.join(file);
if let Err(e) = std::fs::remove_file(&path)
&& e.kind() != std::io::ErrorKind::NotFound
{
log::warn!("can't remove {}: {e}", path.display());
}
}
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 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.
pub fn load_stored_plugins(&mut self) {
let Some(dir) = self.plugin_dir.clone() else { return };
if safe_mode::active() {
return;
}
let grants = stored_grants(&dir);
let limits = vectorcraft_plugins::Limits::default();
let mut left = MAX_PLUGIN_STORE;
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) => {
vectorcraft_plugins::registry::grant(g.clone());
vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id));
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
None => {
log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display())
}
}
}
Ok(plugin) => {
vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id()));
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
}
}
}
/// 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`]), 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(stored_grants);
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),
}
}
}
/// 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<Option<Vec<u8>>, 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.
pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) {
match previous {
Some(bytes) => {
self.store_plugin(id, bytes);
}
None => self.unstore_plugin(id),
}
}
/// 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<String> {
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).
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> 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 {}: {e}", dir.join(GRANTS_FILE).display());
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<serde_json::Value> = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect();
entries.extend(file.other);
if entries.is_empty() {
take(dir, GRANTS_FILE);
return true;
}
// 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<u8>| 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
}
}
}
/// Remove the plug-in `id` from the store.
pub(crate) fn unstore_plugin(&self, id: &str) {
if let Some(dir) = &self.plugin_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).
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`? 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| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok())
}
/// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it.
/// The folder is first renamed out of the way (`<folder>.removed`), and that fails, leaving it
/// as it was, when the rename does; what the deletion then leaves stays under that name, with a
/// log record, and is deleted at the plug-in's next removal.
pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> {
vectorcraft_plugins::registry::set_storage(id, None);
let Some(dir) = &self.plugin_dir else { return Ok(()) };
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(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) {
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());
close();
return Ok(());
}
let mut aside = path.clone().into_os_string();
aside.push(".removed");
let aside = PathBuf::from(aside);
let _ = std::fs::remove_dir_all(&aside);
match std::fs::rename(&path, &aside) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))),
Ok(()) => {}
}
close();
if std::fs::symlink_metadata(&aside).is_ok()
&& let Err(e) = std::fs::remove_dir_all(&aside)
{
log::warn!("can't delete {}: {e}", aside.display());
}
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)))
}
/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`.
fn plugin_storage(dir: Option<&Path>, id: &str) -> Option<Arc<dyn Storage>> {
let dir = dir?;
let data = plugin_data_dir(dir, id);
let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone();
Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed }))
}
/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs).
/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as
/// [`case_safe`] writes it, so that names differing only in case get different files. A link
/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a
/// plain file.
pub(crate) struct PluginStorage {
/// The plug-in store, and the plug-in's folder in it.
root: PathBuf,
dir: PathBuf,
/// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the
/// folder ([`PLUGIN_DATA_OPEN`]).
closed: Arc<AtomicBool>,
}
/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system.
fn sum_sizes(files: &[(PathBuf, u64)]) -> u64 {
files.iter().fold(0, |sum, (_, n)| sum.saturating_add(*n))
}
/// Is `name` the file name of a cache entry? Entry files end in `.bin` and never start with a `.`
/// ([`case_safe`] writes no `.`), while the temporary files of `write_atomic` do.
fn is_entry_file(name: &std::ffi::OsStr) -> bool {
name.to_str().is_some_and(|n| !n.starts_with('.') && n.ends_with(&format!(".{CACHE_EXT}")))
}
/// What a storage call is told when the file system fails.
fn io(e: &std::io::Error) -> StorageError {
StorageError::Io(e.to_string())
}
/// Does grant `g` cover `plugin`: was it given to this module (the same id and SHA-256) for every
/// permission the module lists? A module with other bytes under the id needs the user's approval
/// again, and so doesn't get the settings and cache kept for the id without it. A grant whose hash
/// is missing or not a valid SHA-256 covers no module.
fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool {
let m = plugin.manifest();
g.covers(&m.id, plugin.hash(), &m.permissions())
}
/// Is `path` a link (a symbolic link or, on Windows, a junction)?
fn is_link(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || reparse_point(&m))
}
#[cfg(windows)]
fn reparse_point(m: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn reparse_point(_: &std::fs::Metadata) -> bool {
false
}
impl PluginStorage {
fn settings_path(&self) -> PathBuf {
self.dir.join(SETTINGS_FILE)
}
fn cache_dir(&self) -> PathBuf {
self.dir.join(CACHE_DIR)
}
/// The file of the cache entry `name`.
fn entry_path(&self, name: &str) -> PathBuf {
self.cache_dir().join(format!("{}.{CACHE_EXT}", case_safe(name)))
}
/// Checks that no folder between the store and `path`, nor `path` itself, is a link.
fn no_links(&self, path: &Path) -> Result<(), StorageError> {
let mut at = path;
while at != self.root {
if is_link(at) {
return Err(StorageError::Unavailable);
}
match at.parent() {
Some(up) => at = up,
None => break,
}
}
Ok(())
}
/// The settings, by key. A file that is too large or doesn't parse is moved aside
/// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the
/// file's size before it is read.
fn read_settings(&self, meter: &mut Meter) -> Result<BTreeMap<String, Vec<u8>>, StorageError> {
let path = self.settings_path();
self.no_links(&path)?;
let size = match std::fs::metadata(&path) {
Ok(m) => m.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(io(&e)),
};
let readable = size <= storage::MAX_SETTINGS_BYTES as u64;
if readable {
meter.take(size)?;
}
let parsed = readable
.then(|| read_capped(&path, storage::MAX_SETTINGS_BYTES as u64).ok())
.flatten()
.and_then(|bytes| serde_json::from_slice::<BTreeMap<String, String>>(&bytes).ok())
.and_then(|stored| {
let decode =
|(k, v): (String, String)| if storage::valid_name(&k) { vectorcraft_format::base64_decode(&v).map(|v| (k, v)) } else { None };
let settings: Option<BTreeMap<String, Vec<u8>>> = stored.into_iter().map(decode).collect();
settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES))
});
match parsed {
Some(settings) => Ok(settings),
None => {
let aside = self.dir.join(BAD_SETTINGS_FILE);
log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display());
std::fs::rename(&path, &aside).map_err(|e| io(&e))?;
Ok(BTreeMap::new())
}
}
}
/// Writes `settings` as the settings file, or removes it when there are none. `meter` is charged
/// the new file's size before it is written.
fn write_settings(&self, settings: &BTreeMap<String, Vec<u8>>, meter: &mut Meter) -> Result<(), StorageError> {
let path = self.settings_path();
if settings.is_empty() {
return match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io(&e)),
_ => Ok(()),
};
}
let stored: BTreeMap<&str, String> = settings.iter().map(|(k, v)| (k.as_str(), vectorcraft_format::base64_encode(v))).collect();
let bytes = serde_json::to_vec(&stored).map_err(|e| StorageError::Io(e.to_string()))?;
if bytes.len() > storage::MAX_SETTINGS_BYTES {
return Err(StorageError::Full);
}
meter.take(bytes.len() as u64)?;
std::fs::create_dir_all(&self.dir).map_err(|e| io(&e))?;
self.no_links(&path)?;
write_atomic(&path, &bytes).map_err(|e| io(&e))
}
/// Fails with [`StorageError::Unavailable`] once the storage is closed.
fn open(&self) -> Result<(), StorageError> {
if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) }
}
/// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which
/// leaves out the temporary files of writes) and their sizes, and whether the folder holds more
/// than [`MAX_CACHE_SCAN`] items, in which case only the entries among the first that many are
/// listed.
fn cache_files(&self) -> Result<(Vec<(PathBuf, u64)>, bool), StorageError> {
let dir = self.cache_dir();
self.no_links(&dir)?;
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], false)),
Err(e) => return Err(io(&e)),
};
let mut files = Vec::new();
for (seen, e) in entries.enumerate() {
if seen == MAX_CACHE_SCAN {
return Ok((files, true));
}
let e = e.map_err(|e| io(&e))?;
if is_entry_file(&e.file_name())
&& let Ok(m) = std::fs::symlink_metadata(e.path())
&& m.is_file()
&& !reparse_point(&m)
{
files.push((e.path(), m.len()));
}
}
Ok((files, false))
}
}
impl Storage for PluginStorage {
fn setting(&self, key: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
Ok(self.read_settings(meter)?.remove(key))
}
fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let mut settings = self.read_settings(meter)?;
if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) {
return Err(StorageError::Full);
}
settings.insert(key.to_string(), value.to_vec());
self.write_settings(&settings, meter)
}
fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result<bool, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let mut settings = self.read_settings(meter)?;
if settings.remove(key).is_none() {
return Ok(false);
}
self.write_settings(&settings, meter).map(|()| true)
}
fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let path = self.entry_path(name);
self.no_links(&path)?;
match std::fs::symlink_metadata(&path) {
Ok(m) if m.is_file() && m.len() <= storage::MAX_CACHE_ENTRY as u64 => meter.take(m.len())?,
Ok(_) => return Err(StorageError::Unavailable),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io(&e)),
}
read_capped(&path, storage::MAX_CACHE_ENTRY as u64).map(Some).map_err(StorageError::Io)
}
fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let path = self.entry_path(name);
let (files, overfull) = self.cache_files()?;
if overfull {
return Err(StorageError::Full);
}
let old = files.iter().find(|(p, _)| *p == path).map(|(_, n)| *n);
let total = sum_sizes(&files).saturating_sub(old.unwrap_or(0)).saturating_add(value.len() as u64);
let too_many = old.is_none() && files.len() >= storage::MAX_CACHE_ENTRIES;
if value.len() > storage::MAX_CACHE_ENTRY || total > storage::MAX_CACHE_BYTES || too_many {
return Err(StorageError::Full);
}
meter.take(value.len() as u64)?;
std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?;
self.no_links(&path)?;
write_atomic(&path, value).map_err(|e| io(&e))
}
fn remove_cache_entry(&self, name: &str) -> Result<bool, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let path = self.entry_path(name);
self.no_links(&path)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(io(&e)),
}
}
fn clear_cache(&self) -> Result<u64, StorageError> {
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
self.open()?;
let dir = self.cache_dir();
self.no_links(&dir)?;
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(io(&e)),
};
// Every plain file, one at a time as the folder is read, past the ones a write looks at too.
let mut freed = 0_u64;
for e in entries {
let path = e.map_err(|e| io(&e))?.path();
if let Ok(m) = std::fs::symlink_metadata(&path)
&& m.is_file()
&& !reparse_point(&m)
{
std::fs::remove_file(&path).map_err(|e| io(&e))?;
freed = freed.saturating_add(m.len());
}
}
Ok(freed)
}
fn cache_bytes(&self) -> u64 {
if self.open().is_err() {
return 0;
}
self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0)
}
}
/// What the grants file of a plug-in store holds.
#[derive(Default)]
struct GrantsFile {
/// The grants this version can use, by plug-in id.
grants: BTreeMap<String, vectorcraft_plugins::permissions::Grant>,
/// The entries it can't use (a permission it doesn't know, an invalid id), as they were.
other: Vec<serde_json::Value>,
}
/// The grants file of the plug-in store `dir`: empty when there is none, an error when it is too
/// large, can't be read or doesn't hold a list. An entry that doesn't parse as a grant, or names an
/// invalid id, is kept in [`GrantsFile::other`] with a log record.
fn read_grants(dir: &Path) -> Result<GrantsFile, String> {
let path = dir.join(GRANTS_FILE);
if !path.exists() {
return Ok(GrantsFile::default());
}
let entries =
read_capped(&path, MAX_GRANTS_FILE).and_then(|bytes| serde_json::from_slice::<Vec<serde_json::Value>>(&bytes).map_err(|e| e.to_string()))?;
let mut file = GrantsFile::default();
let mut first_error = None;
for entry in entries {
let why = match serde_json::from_value::<vectorcraft_plugins::permissions::Grant>(entry.clone()) {
Ok(g) if vectorcraft_plugins::manifest::valid_id(&g.id) => {
file.grants.insert(g.id.clone(), g);
continue;
}
Ok(g) => format!("invalid id `{}`", g.id.escape_debug()),
Err(e) => e.to_string(),
};
first_error.get_or_insert(why);
file.other.push(entry);
}
if let Some(why) = first_error {
log::warn!("skipping {} stored plug-in grants in {} (the first: {why})", file.other.len(), path.display());
}
Ok(file)
}
/// The grants kept in the plug-in store `dir` that this version can use, by plug-in id: none when
/// there is no grants file, and none, with a log record, when it is too large, can't be read or
/// doesn't hold a list.
fn stored_grants(dir: &Path) -> BTreeMap<String, vectorcraft_plugins::permissions::Grant> {
match read_grants(dir) {
Ok(file) => file.grants,
Err(e) => {
log::warn!("skipping the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display());
BTreeMap::new()
}
}
}
/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a
/// built-in preset, at most [`MAX_STORE_FILES`].
fn load_presets(dir: &Path) -> Vec<PdfPreset> {
let mut presets: Vec<PdfPreset> = vec![];
for path in list_files(dir, PRESET_FORMAT) {
let file = read_capped(&path, MAX_PRESET_FILE).and_then(|bytes| serde_json::from_slice::<PresetFile>(&bytes).map_err(|e| e.to_string()));
let file = match file {
Ok(f) if f.format == PRESET_FORMAT => f,
Ok(f) => {
log::warn!("skipping {}: not a PDF presets file (format `{}`)", path.display(), f.format);
continue;
}
Err(e) => {
log::warn!("skipping the stored PDF preset {}: {e}", path.display());
continue;
}
};
for mut preset in file.presets {
let name = preset.name.trim().to_string();
let usable = !name.is_empty()
&& builtin_preset(&name).is_none()
&& !presets.iter().any(|q| q.name.eq_ignore_ascii_case(&name))
&& preset.settings.check_values().is_ok()
&& presets.len() < MAX_STORE_FILES;
if usable {
preset.name = name;
preset.settings.clear_passwords();
presets.push(preset);
} else {
log::warn!("skipping the PDF preset `{name}` of {}", path.display());
}
}
}
presets
}