Files
vectorcraft/crates/engine/src/store.rs
T
dotCooCoo 960bc64197 Plug-ins: recording a grant keeps the grants file entries it can't read
A grants.json entry this version can't use (a permission it doesn't know,
an invalid id) is now skipped on its own instead of dropping every grant in
the file, and recording or removing a grant writes it back unchanged,
except an entry for that plug-in. A grants.json that is too large, can't be
read or is not a JSON list is left as it is, and a rewrite that would pass
the 1 MiB read limit is written compactly, or not at all.
2026-11-08 13:21:16 +07:00

426 lines
20 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. 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.
//!
//! 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 vectorcraft_pdf::{PdfPreset, builtin_preset};
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 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 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"))
}
/// 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`, and install the ones already there (a
/// module that doesn't load, and one that lists permissions the grants there don't grant it,
/// is skipped with a log record).
pub fn attach_plugin_store(&mut self, dir: PathBuf) {
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 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())
});
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| g.covers(&m.permissions())) {
Some(g) => {
vectorcraft_plugins::registry::grant(g.clone());
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()),
}
}
Ok(plugin) => {
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
}
}
self.plugin_dir = Some(dir);
}
/// Whether `plugin` installs without asking the user: it lists no permissions, or its id was
/// granted every one it lists, in the grants of the attached plug-in store when there is one,
/// 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| g.covers(&m.permissions())),
_ => vectorcraft_plugins::registry::approved(plugin),
}
}
}
/// Write the module `bytes` of the plug-in `id` to the store.
pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) {
if let Some(dir) = &self.plugin_dir {
put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes);
}
}
/// 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`.
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) {
let Some(dir) = &self.plugin_dir else { return };
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;
}
};
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;
}
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;
}
// 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)),
}
}
/// 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)));
}
}
}
/// 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
}