mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
* 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>
147 lines
7.5 KiB
Rust
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);
|
|
}
|
|
}
|