mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 13:32:23 +00:00
plugin.install and plugin.remove count an entry of grants.json that this version can't use when it can't be removed. plugin.install stops counting a grant it can't remove when the old module can't be written back to the Plug-ins folder.
1149 lines
54 KiB
Rust
1149 lines
54 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, 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::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<BTreeMap<PathBuf, Arc<PluginData>>> = 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 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<Vec<u8>> {
|
|
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")
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
#[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<bool> = 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);
|
|
match std::fs::remove_file(&path) {
|
|
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 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);
|
|
if self.plugin_dir.as_ref() != Some(&dir) {
|
|
self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
|
}
|
|
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
|
|
/// the rest of the session ([`Session::plugin_approver`]).
|
|
pub fn load_stored_plugins(&mut self) {
|
|
let Some(dir) = self.plugin_dir.clone() else { return };
|
|
if safe_mode::active() {
|
|
return;
|
|
}
|
|
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(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) => {
|
|
asks_nothing.insert(plugin.id().to_string());
|
|
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()),
|
|
}
|
|
}
|
|
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 && !self.store_grant(id, None) && self.holds_plugin_grant(id) {
|
|
self.ignore_stored_grant(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session
|
|
/// doesn't count ([`Session::ignore_stored_grant`]).
|
|
fn counted_grants(&self, dir: &Path) -> BTreeMap<String, vectorcraft_plugins::permissions::Grant> {
|
|
let ignored = self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner());
|
|
let mut grants = stored_grants(dir);
|
|
grants.retain(|id, _| !ignored.contains(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<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. → whether the
|
|
/// store now holds `previous`.
|
|
pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool {
|
|
#[cfg(test)]
|
|
if PUT_BACK_FAILS.get() {
|
|
return false;
|
|
}
|
|
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). When it does, the grant kept for `id` counts
|
|
/// again ([`Session::ignore_stored_grant`]).
|
|
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool {
|
|
let done = self.write_grant(id, grant);
|
|
if done {
|
|
self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).remove(id);
|
|
}
|
|
done
|
|
}
|
|
|
|
/// Stop counting the store's grant for the plug-in `id` until [`Session::store_grant`] next
|
|
/// succeeds for it or another store is attached: for a grant that should be gone and could not
|
|
/// be removed, which would otherwise approve the bytes it covers.
|
|
pub(crate) fn ignore_stored_grant(&self, id: &str) {
|
|
self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string());
|
|
}
|
|
|
|
fn write_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 {}: {}", 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<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() {
|
|
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<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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
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. A later call for the id tries to delete it again. `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.
|
|
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 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(()) => {}
|
|
}
|
|
if !forget() {
|
|
if std::fs::symlink_metadata(&aside).is_ok()
|
|
&& let Err(e) = std::fs::rename(&aside, &path)
|
|
{
|
|
log::warn!("can't move {} back: {e}", aside.display());
|
|
}
|
|
return Err(not_forgotten());
|
|
}
|
|
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 state = 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, state }))
|
|
}
|
|
|
|
/// A file's size and modified time (`None`: there is no file).
|
|
type FileStamp = Option<(u64, SystemTime)>;
|
|
|
|
/// The stamp of the file at `path`.
|
|
fn file_stamp(path: &Path) -> Result<FileStamp, StorageError> {
|
|
match std::fs::metadata(path) {
|
|
Ok(m) => Ok(Some((m.len(), m.modified().map_err(|e| io(&e))?))),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(io(&e)),
|
|
}
|
|
}
|
|
|
|
/// The modified time of the folder at `dir` (`None`: it can't be read, or there is no folder).
|
|
fn folder_stamp(dir: &Path) -> Option<SystemTime> {
|
|
std::fs::symlink_metadata(dir).ok()?.modified().ok()
|
|
}
|
|
|
|
/// What every storage made for one plug-in folder shares ([`PLUGIN_DATA_OPEN`]).
|
|
#[derive(Default)]
|
|
struct PluginData {
|
|
/// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed.
|
|
closed: AtomicBool,
|
|
kept: Mutex<Kept>,
|
|
}
|
|
|
|
/// What the folder's files last held in this process.
|
|
#[derive(Default)]
|
|
struct Kept {
|
|
settings: Option<KeptSettings>,
|
|
cache: Option<KeptCache>,
|
|
}
|
|
|
|
/// The settings by key, and the settings file's stamp when they were read or written.
|
|
struct KeptSettings {
|
|
stamp: FileStamp,
|
|
values: BTreeMap<String, Vec<u8>>,
|
|
}
|
|
|
|
/// The bytes of the cache entries, and the cache folder's stamp when they were added up.
|
|
#[derive(Clone, Copy)]
|
|
struct KeptCache {
|
|
stamp: Option<SystemTime>,
|
|
bytes: u64,
|
|
}
|
|
|
|
/// 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. The settings stay in memory ([`Kept`]) while the settings file keeps its size and
|
|
/// modified time, and every change is written to the file before the call returns. The total
|
|
/// `cache_bytes` reports stays in memory while the cache folder keeps its modified time.
|
|
pub(crate) struct PluginStorage {
|
|
/// The plug-in store, and the plug-in's folder in it.
|
|
root: PathBuf,
|
|
dir: PathBuf,
|
|
state: Arc<PluginData>,
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
|
|
fn kept(&self) -> MutexGuard<'_, Kept> {
|
|
self.state.kept.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// The settings: those in `kept` when the settings file still has their stamp, else the file's,
|
|
/// which then replace them.
|
|
fn read_settings<'a>(&self, kept: &'a mut Option<KeptSettings>, meter: &mut Meter) -> Result<&'a mut KeptSettings, StorageError> {
|
|
let path = self.settings_path();
|
|
self.no_links(&path)?;
|
|
let stamp = file_stamp(&path)?;
|
|
let held = match kept.take() {
|
|
Some(held) if held.stamp == stamp => held,
|
|
_ => self.load_settings(stamp, meter)?,
|
|
};
|
|
Ok(kept.insert(held))
|
|
}
|
|
|
|
/// Reads the settings file, whose stamp is `stamp`. 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 load_settings(&self, stamp: FileStamp, meter: &mut Meter) -> Result<KeptSettings, StorageError> {
|
|
let path = self.settings_path();
|
|
let Some((size, _)) = stamp else { return Ok(KeptSettings { stamp, values: BTreeMap::new() }) };
|
|
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(values) => Ok(KeptSettings { stamp, values }),
|
|
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(KeptSettings { stamp: None, values: BTreeMap::new() })
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Writes the settings in `kept` to the file and records the file's new stamp. When that fails,
|
|
/// or settings were written and the file is gone, `kept` is emptied, so the next call reads the
|
|
/// file again.
|
|
fn save_settings(&self, kept: &mut Option<KeptSettings>, meter: &mut Meter) -> Result<(), StorageError> {
|
|
let Some(held) = kept.as_mut() else { return Ok(()) };
|
|
let written = self.write_settings(&held.values, meter);
|
|
match (written, file_stamp(&self.settings_path())) {
|
|
(Ok(()), Ok(stamp)) if stamp.is_some() || held.values.is_empty() => {
|
|
held.stamp = stamp;
|
|
Ok(())
|
|
}
|
|
(written, _) => {
|
|
*kept = None;
|
|
written
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.state.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()?;
|
|
let mut kept = self.kept();
|
|
Ok(self.read_settings(&mut kept.settings, meter)?.values.get(key).cloned())
|
|
}
|
|
|
|
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 kept = self.kept();
|
|
let settings = &mut self.read_settings(&mut kept.settings, meter)?.values;
|
|
if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) {
|
|
return Err(StorageError::Full);
|
|
}
|
|
if settings.get(key).is_some_and(|old| old == value) {
|
|
return Ok(());
|
|
}
|
|
settings.insert(key.to_string(), value.to_vec());
|
|
self.save_settings(&mut kept.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 kept = self.kept();
|
|
if self.read_settings(&mut kept.settings, meter)?.values.remove(key).is_none() {
|
|
return Ok(false);
|
|
}
|
|
self.save_settings(&mut kept.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))?;
|
|
let stamp = folder_stamp(&self.cache_dir());
|
|
self.kept().cache = stamp.map(|_| KeptCache { stamp, bytes: total });
|
|
Ok(())
|
|
}
|
|
|
|
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(()) => {
|
|
self.kept().cache = None;
|
|
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());
|
|
}
|
|
}
|
|
self.kept().cache = Some(KeptCache { stamp: folder_stamp(&dir), bytes: 0 });
|
|
Ok(freed)
|
|
}
|
|
|
|
fn cache_bytes(&self) -> u64 {
|
|
if self.open().is_err() {
|
|
return 0;
|
|
}
|
|
let stamp = folder_stamp(&self.cache_dir());
|
|
if let Some(kept) = self.kept().cache.filter(|k| k.stamp == stamp) {
|
|
return kept.bytes;
|
|
}
|
|
let Ok((files, _)) = self.cache_files() else { return 0 };
|
|
let bytes = sum_sizes(&files);
|
|
self.kept().cache = Some(KeptCache { stamp, bytes });
|
|
bytes
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
impl GrantsFile {
|
|
/// Whether the file has an entry for the plug-in `id`, usable by this version or not.
|
|
fn names(&self, id: &str) -> bool {
|
|
self.grants.contains_key(id) || self.other.iter().any(|e| e.get("id").and_then(serde_json::Value::as_str) == Some(id))
|
|
}
|
|
}
|
|
|
|
/// Why the grants file of a plug-in store gave no grants.
|
|
struct GrantsError {
|
|
/// Opening or reading the file failed, so what it holds is unknown. When `false`, the file is
|
|
/// too large or doesn't hold a list, and approves nothing.
|
|
unread: bool,
|
|
/// The detail for a log record.
|
|
why: String,
|
|
}
|
|
|
|
/// 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, GrantsError> {
|
|
let path = dir.join(GRANTS_FILE);
|
|
let bytes = match read_past_cap(&path, MAX_GRANTS_FILE) {
|
|
Ok(bytes) => bytes,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(GrantsFile::default()),
|
|
Err(e) => return Err(GrantsError { unread: true, why: e.to_string() }),
|
|
};
|
|
if bytes.len() as u64 > MAX_GRANTS_FILE {
|
|
return Err(GrantsError { unread: false, why: too_large(MAX_GRANTS_FILE) });
|
|
}
|
|
let entries = serde_json::from_slice::<Vec<serde_json::Value>>(&bytes).map_err(|e| GrantsError { unread: false, why: 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 {}: {}", dir.join(GRANTS_FILE).display(), e.why);
|
|
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
|
|
}
|