Files
effectcraft/crates/engine/src/services.rs
T
a439b1bb19 Effect ▸ Install Effect Plug-in… and plug-in bundles (wasm + ScriptUI panel in one go) (#2749)
* plugins: install effect plug-in bundles (wasm + ScriptUI panels) in one go

* plugins: address review - keep listing errors, refuse replacing an installed plug-in

* plugins: report unreadable bundle entries when their metadata cannot be read

* plugins: surface partial install errors in the menu bar, cover the install flow with tests

* plugins: surface partial install errors for async dialog answers too

* plugins: show result errors as compact status text

* plugins: harden installs - confined automation, no panel overwrite, size cap, cleanup errors

* plugins: bound install reads from one handle, detect unreadable panels, clarify install docs

* plugins: drop an unneeded mut in the bounded read

* plugins: saturate the bounded read limit

* plugins: install into the session's Plug-ins folder, refuse hidden single installs and case-variant panels

- Install .wasm files into Session::plugins_dir() (the folder the Plug-in Library
  from #2524 uses), not a path rebuilt from the settings folder; the host tests
  now point both at their temp folder so they never touch the real settings.
- A single install of a hidden file (".x.wasm") was skipped and reported as a
  success that installed nothing: it is now an error.
- Installed names must be bare file names (and not Windows device names on
  Windows), as file.installScript requires, so every installed panel is listed
  and can be uninstalled.
- A panel whose name differs from an installed one only in case replaced it on
  Windows and macOS: compare ignoring case and check the file on disk too.
- The loaded plug-in's source is the installed copy, not the file it came from.
- Document the `bundle` menu parameter, so checked execution accepts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* plugins: drop needless to_vec in the bundle install test (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* plugins: keep the loader test's byte vectors, drop to_vec in the install test (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: STAR Web Studio <hello.starwebstudio@gmail.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:35:54 +01:00

147 lines
7.5 KiB
Rust

use super::config;
/// Host services (file access) injected by the frontend.
pub trait Services: Send + Sync {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
/// [`Services::read_file`] for a copy that must not load an unbounded file: more than `max`
/// bytes is an error. The native one opens the file once, reads its size from that handle and
/// reads through a [`std::io::Read::take`] limit, so a file grown or swapped after the check
/// still cannot read past `max`.
fn read_file_bounded(&self, path: &str, max: u64) -> std::io::Result<Vec<u8>> {
let bytes = self.read_file(path)?;
if bytes.len() as u64 > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{path} is larger than the {max}-byte limit")));
}
Ok(bytes)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>;
/// Whether a file exists (the background footage check after open).
fn exists(&self, path: &str) -> bool {
std::path::Path::new(path).exists()
}
/// A file's size and modification time (nanoseconds since 1970), to notice that it changed
/// on disk (Settings ▸ Import ▸ Reload Footage Changed on Disk); `None` when it can't be read.
fn stamp(&self, path: &str) -> Option<(u64, u128)> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
/// Keep a file the app made for itself (footage extracted from a template): like
/// [`Services::write_file`], creating its folder, but never offered as a download (web: it
/// goes to browser storage only).
fn store_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
if let Some(d) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
let _ = std::fs::create_dir_all(d);
}
self.write_file(path, data)
}
/// The files in folder `dir` (full paths; no folders, no hidden files), for image-sequence
/// detection on import. Empty when it can't be read.
fn list_dir(&self, dir: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(if dir.is_empty() { "." } else { dir }) else { return vec![] };
rd.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()) && !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| e.path().to_string_lossy().to_string())
.collect()
}
/// Whether `path` is a folder (dropped or picked folders import their files).
fn is_dir(&self, path: &str) -> bool {
std::path::Path::new(path).is_dir()
}
/// `path` as a list of recent files keeps it (File ▸ Scripts ▸ Recent Script Files): a
/// relative path is made absolute against the working directory, so it opens the same file
/// after the working directory changes. `None` when the path can't be made absolute (the
/// working directory can't be read); the script is then not added to the list.
fn full_path(&self, path: &str) -> Option<String> {
std::path::absolute(path).ok().map(|p| p.to_string_lossy().into_owned())
}
}
/// Native filesystem.
pub struct FsServices;
impl Services for FsServices {
/// Refuses what isn't a regular file: a project can name `/dev/zero` or a FIFO, and reading
/// those never ends (#2134).
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file")));
}
let mut bytes = Vec::new();
// A sparse file can claim far more than memory: an error, not an abort.
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
fn read_file_bounded(&self, path: &str, max: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file")));
}
if meta.len() > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{path} is larger than the {max}-byte limit")));
}
let mut bytes = Vec::new();
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
// The size above came from this handle, and the read is capped too: a file that grows
// after the check still cannot read past `max`. Saturating, so `u64::MAX` cannot overflow.
file.take(max.saturating_add(1)).read_to_end(&mut bytes)?;
if bytes.len() as u64 > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{path} is larger than the {max}-byte limit")));
}
Ok(bytes)
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
config::atomic_write(std::path::Path::new(path), data)
}
}
/// Imports media files into the project (implemented by the media layer).
pub trait Importer: Send + Sync {
/// Probe one file and return footage metadata. Image sequences are the engine's
/// ([`sequence`]): it groups numbered stills and probes their first file here.
fn probe(&self, path: &str) -> std::result::Result<effectcraft_project::Footage, String>;
/// A footage file's bytes are now at `path` (extracted from a template): hosts whose media
/// layer reads from memory (the browser) register them; file-based ones need nothing.
fn register(&self, _path: &str, _data: &[u8]) {}
/// An image sequence's other frames (its first was probed) are footage now: hosts whose
/// media layer reads from memory (the browser) register them; file-based ones need nothing.
fn add_frames(&self, _paths: &[String]) {}
}
#[cfg(test)]
mod tests {
use super::{FsServices, Services};
/// #2134: a project can name a folder, `/dev/zero` or a FIFO; reading those is an error.
#[test]
fn fs_services_read_only_regular_files() {
let dir = std::env::temp_dir();
assert!(FsServices.read_file(&dir.to_string_lossy()).is_err());
#[cfg(unix)]
assert!(FsServices.read_file("/dev/zero").is_err());
let f = dir.join(format!("ec-services-read-{}.txt", std::process::id()));
std::fs::write(&f, b"data").unwrap();
assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data");
let _ = std::fs::remove_file(f);
}
/// The limit is inclusive and cannot overflow: `u64::MAX` reads the file, one byte too many
/// is an error, and nothing past it is read.
#[test]
fn fs_services_bounded_read_stops_at_the_limit() {
let f = std::env::temp_dir().join(format!("ec-services-bounded-{}.txt", std::process::id()));
std::fs::write(&f, b"data").unwrap();
let p = f.to_string_lossy().to_string();
assert_eq!(FsServices.read_file_bounded(&p, u64::MAX).unwrap(), b"data");
assert_eq!(FsServices.read_file_bounded(&p, 4).unwrap(), b"data");
assert!(FsServices.read_file_bounded(&p, 3).is_err());
let _ = std::fs::remove_file(f);
}
}