mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:33:54 +00:00
311 lines
14 KiB
Rust
311 lines
14 KiB
Rust
//! What a plug-in may do: the permissions its manifest lists (`permissions`), which the user
|
|
//! approves when it is installed ([`Grant`], kept in [`crate::registry`]), and which the host
|
|
//! enforces on every run.
|
|
//!
|
|
//! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in
|
|
//! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without
|
|
//! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of
|
|
//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host
|
|
//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{Error, Kind, Result};
|
|
|
|
/// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most
|
|
/// this.
|
|
pub const API_LEVEL: u32 = 2;
|
|
|
|
/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress`
|
|
/// permission they need.
|
|
pub const PROGRESS_API: u32 = 2;
|
|
|
|
/// Most permissions a manifest may list.
|
|
pub const MAX_PERMISSIONS: usize = 16;
|
|
|
|
/// Longest plug-in name a prompt shows, in characters.
|
|
pub const MAX_SHOWN_NAME: usize = 64;
|
|
|
|
/// Longest version or id a prompt shows, in characters.
|
|
pub const MAX_SHOWN_FIELD: usize = 64;
|
|
|
|
/// Names kept for host calls a later plug-in API adds. This host grants none of them, so a
|
|
/// manifest that lists one is refused when it is installed.
|
|
pub const RESERVED: &[&str] = &["ui.panel", "ui.tool"];
|
|
|
|
/// One thing a plug-in may do.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
pub enum Permission {
|
|
/// Read the objects the host hands it: a filter's selected paths, the object a live effect is
|
|
/// on, the document an exporter writes.
|
|
#[serde(rename = "doc.read")]
|
|
DocRead,
|
|
/// Change, add and delete objects (a filter's output), as one undo step.
|
|
#[serde(rename = "doc.write")]
|
|
DocWrite,
|
|
/// Read and write the data it keeps with the document (a filter's `documentData`).
|
|
#[serde(rename = "state")]
|
|
State,
|
|
/// Change how the objects its live effect is applied to are drawn.
|
|
#[serde(rename = "effect.render")]
|
|
EffectRender,
|
|
/// Write the document as a file in its own format (an exporter).
|
|
#[serde(rename = "export.write")]
|
|
ExportWrite,
|
|
/// Read a file the user opens and make a new document from it (an importer).
|
|
#[serde(rename = "import.read")]
|
|
ImportRead,
|
|
/// Report how far a run has got, see that the user asked it to stop and write log lines: the
|
|
/// host imports of API level 2 ([`crate::host::IMPORTS`]).
|
|
#[serde(rename = "progress")]
|
|
Progress,
|
|
}
|
|
|
|
/// Every permission this host grants, in the order prompts list them.
|
|
pub const ALL: [Permission; 7] = [
|
|
Permission::DocRead,
|
|
Permission::DocWrite,
|
|
Permission::State,
|
|
Permission::EffectRender,
|
|
Permission::ExportWrite,
|
|
Permission::ImportRead,
|
|
Permission::Progress,
|
|
];
|
|
|
|
impl Permission {
|
|
/// Its name in a manifest and in `plugin.install`'s `grant`.
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Permission::DocRead => "doc.read",
|
|
Permission::DocWrite => "doc.write",
|
|
Permission::State => "state",
|
|
Permission::EffectRender => "effect.render",
|
|
Permission::ExportWrite => "export.write",
|
|
Permission::ImportRead => "import.read",
|
|
Permission::Progress => "progress",
|
|
}
|
|
}
|
|
|
|
/// The permission named `name`.
|
|
pub fn parse(name: &str) -> Option<Permission> {
|
|
ALL.into_iter().find(|p| p.name() == name)
|
|
}
|
|
|
|
/// What it lets a plug-in do, in plain words (English; the app shows its own translation).
|
|
pub fn describe(self) -> &'static str {
|
|
match self {
|
|
Permission::DocRead => "Read the objects it is given",
|
|
Permission::DocWrite => "Change, add and delete objects in the document",
|
|
Permission::State => "Keep data of its own with the document",
|
|
Permission::EffectRender => "Change how the objects its effect is applied to are drawn",
|
|
Permission::ExportWrite => "Write the document as a file in its own format",
|
|
Permission::ImportRead => "Read files you open in its format and make documents from them",
|
|
Permission::Progress => "Show its progress, see Cancel and write log lines",
|
|
}
|
|
}
|
|
|
|
/// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may
|
|
/// list.
|
|
pub fn applies(self, kind: Kind) -> bool {
|
|
self == Permission::Progress || Permission::implied(kind).contains(&self)
|
|
}
|
|
|
|
/// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list
|
|
/// `progress` ([`Permission::applies`]).
|
|
pub fn implied(kind: Kind) -> &'static [Permission] {
|
|
match kind {
|
|
Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State],
|
|
Kind::Effect => &[Permission::DocRead, Permission::EffectRender],
|
|
Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite],
|
|
Kind::Importer => &[Permission::ImportRead],
|
|
}
|
|
}
|
|
|
|
/// The permissions a plug-in of `kind` can't work without, which its list must hold: a live
|
|
/// effect and an exporter are always handed objects, and only draw or write; an importer is
|
|
/// always handed the file being opened. A filter may leave any of its permissions out.
|
|
fn required(kind: Kind) -> &'static [Permission] {
|
|
match kind {
|
|
Kind::Filter => &[],
|
|
Kind::Effect | Kind::Exporter | Kind::Importer => Permission::implied(kind),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checks the `permissions` a manifest of `kind` lists → them, in the order [`ALL`] has. A name
|
|
/// that is not a permission, a reserved one, one that doesn't apply to the kind, one listed twice
|
|
/// and a list without what the kind needs are errors that name it.
|
|
pub(crate) fn validate(kind: Kind, names: &[String]) -> Result<Vec<Permission>> {
|
|
if names.len() > MAX_PERMISSIONS {
|
|
return Err(Error::Manifest(format!("at most {MAX_PERMISSIONS} `permissions`")));
|
|
}
|
|
let mut out: Vec<Permission> = Vec::with_capacity(names.len());
|
|
for name in names {
|
|
let shown = display_text(name, MAX_SHOWN_FIELD);
|
|
let Some(p) = Permission::parse(name) else {
|
|
if RESERVED.contains(&name.as_str()) {
|
|
return Err(Error::Manifest(format!("permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it")));
|
|
}
|
|
return Err(Error::Manifest(format!("unknown permission `{shown}`")));
|
|
};
|
|
if !p.applies(kind) {
|
|
return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name())));
|
|
}
|
|
if out.contains(&p) {
|
|
return Err(Error::Manifest(format!("permission `{shown}` is listed twice")));
|
|
}
|
|
out.push(p);
|
|
}
|
|
if let Some(missing) = Permission::required(kind).iter().find(|p| !out.contains(p)) {
|
|
return Err(Error::Manifest(format!("a plug-in of kind `{}` needs the permission `{}`", kind.name(), missing.name())));
|
|
}
|
|
out.sort();
|
|
Ok(out)
|
|
}
|
|
|
|
/// The names of `permissions`, as a manifest and `grant` list them.
|
|
pub fn names(permissions: &[Permission]) -> Vec<&'static str> {
|
|
permissions.iter().map(|p| p.name()).collect()
|
|
}
|
|
|
|
/// Is `c` a character that changes the direction text is laid out in, or a line or paragraph
|
|
/// separator, which a prompt must not let a manifest string use to disguise itself?
|
|
fn is_bidi_or_separator(c: char) -> bool {
|
|
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{2028}' | '\u{2029}')
|
|
}
|
|
|
|
/// `s` as a prompt shows a string from a manifest: control, bidirectional formatting and line
|
|
/// separator characters written as `\u{202E}` escapes, and at most `max` characters, the last of
|
|
/// them `…` when it was cut.
|
|
pub fn display_text(s: &str, max: usize) -> String {
|
|
use std::fmt::Write as _;
|
|
let mut out = String::new();
|
|
// Characters written so far, and the byte length of the longest prefix of whole pieces
|
|
// shorter than `max` characters, where the ellipsis goes when the text is cut.
|
|
let mut count = 0;
|
|
let mut cut = 0;
|
|
for c in s.chars() {
|
|
let start = out.len();
|
|
let n = if c.is_control() || is_bidi_or_separator(c) {
|
|
let _ = write!(out, "\\u{{{:04X}}}", c as u32);
|
|
out.len() - start
|
|
} else {
|
|
out.push(c);
|
|
1
|
|
};
|
|
if count + n > max {
|
|
out.truncate(cut);
|
|
out.push('…');
|
|
return out;
|
|
}
|
|
count += n;
|
|
if count < max {
|
|
cut = out.len();
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The SHA-256 of `bytes`, in lower-case hexadecimal.
|
|
pub fn sha256_hex(bytes: &[u8]) -> String {
|
|
use sha2::{Digest, Sha256};
|
|
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved
|
|
/// ([`sha256_hex`]) and the permissions it was granted.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Grant {
|
|
pub id: String,
|
|
pub hash: String,
|
|
pub permissions: Vec<Permission>,
|
|
}
|
|
|
|
impl Grant {
|
|
/// Does it grant every one of `wanted`?
|
|
pub fn covers(&self, wanted: &[Permission]) -> bool {
|
|
wanted.iter().all(|w| self.permissions.contains(w))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn names_round_trip_and_serialize_as_names() {
|
|
for p in ALL {
|
|
assert_eq!(Permission::parse(p.name()), Some(p));
|
|
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.name()));
|
|
}
|
|
assert_eq!(Permission::parse("document.read"), None);
|
|
assert!(RESERVED.iter().all(|r| Permission::parse(r).is_none()), "reserved names are not granted");
|
|
}
|
|
|
|
#[test]
|
|
fn lists_are_checked_against_the_kind() {
|
|
let list = |names: &[&str]| names.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
|
assert_eq!(validate(Kind::Filter, &list(&["doc.write", "doc.read"])).unwrap(), [Permission::DocRead, Permission::DocWrite]);
|
|
assert_eq!(validate(Kind::Filter, &[]).unwrap(), []);
|
|
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok());
|
|
assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok());
|
|
assert_eq!(validate(Kind::Importer, &list(&["import.read"])).unwrap(), [Permission::ImportRead]);
|
|
// Any kind may list `progress`, which no kind implies.
|
|
assert_eq!(validate(Kind::Filter, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]);
|
|
assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok());
|
|
assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok());
|
|
assert_eq!(validate(Kind::Importer, &list(&["progress", "import.read"])).unwrap(), [Permission::ImportRead, Permission::Progress]);
|
|
assert!(
|
|
[Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress))
|
|
);
|
|
for (kind, names, says) in [
|
|
(Kind::Importer, &["import.read", "doc.read"][..], "`doc.read` doesn't apply to a plug-in of kind `importer`"),
|
|
(Kind::Importer, &[], "needs the permission `import.read`"),
|
|
(Kind::Exporter, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"),
|
|
(Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"),
|
|
(Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"),
|
|
(Kind::Filter, &["progress", "progress"], "`progress` is listed twice"),
|
|
(Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"),
|
|
(Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"),
|
|
(Kind::Effect, &["doc.read"], "needs the permission `effect.render`"),
|
|
(Kind::Exporter, &["export.write"], "needs the permission `doc.read`"),
|
|
] {
|
|
let e = validate(kind, &list(names)).unwrap_err().to_string();
|
|
assert!(e.contains(says), "{names:?}: {e}");
|
|
}
|
|
let many = list(&["doc.read"; MAX_PERMISSIONS + 1]);
|
|
assert!(validate(Kind::Filter, &many).unwrap_err().to_string().contains("at most"));
|
|
}
|
|
|
|
/// Control, bidirectional override and separator characters show as escapes, so a name can't
|
|
/// reverse or hide the text after it; long text is cut with an ellipsis.
|
|
#[test]
|
|
fn prompt_text_escapes_hidden_characters_and_is_capped() {
|
|
assert_eq!(display_text("Tidy\u{202E}fdp.exe", 64), "Tidy\\u{202E}fdp.exe");
|
|
assert_eq!(display_text("a\u{0007}b\nc\u{2066}d\u{200F}", 64), "a\\u{0007}b\\u{000A}c\\u{2066}d\\u{200F}");
|
|
assert_eq!(display_text("plain", 64), "plain");
|
|
assert_eq!(display_text("abcdef", 6), "abcdef");
|
|
assert_eq!(display_text("abcdefg", 6), "abcde…");
|
|
assert_eq!(display_text(&"x".repeat(500), MAX_SHOWN_NAME).chars().count(), MAX_SHOWN_NAME);
|
|
assert_eq!(display_text("ab\u{202E}", 6), "ab…", "an escape that doesn't fit is cut whole");
|
|
assert_eq!(display_text("ab\u{0007}", 10), "ab\\u{0007}", "an escape that ends exactly at the cap is kept");
|
|
let long = display_text(&"\u{202E}y".repeat(500_000), MAX_SHOWN_NAME);
|
|
assert_eq!(long.chars().count(), MAX_SHOWN_NAME);
|
|
assert!(long.starts_with("\\u{202E}y\\u{202E}y") && long.ends_with('…'), "{long}");
|
|
}
|
|
|
|
#[test]
|
|
fn hashes_are_sha256_in_hex() {
|
|
assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_covers_its_permissions_and_no_more() {
|
|
let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
|
|
assert!(g.covers(&[Permission::DocRead]));
|
|
assert!(g.covers(&[]));
|
|
assert!(!g.covers(&[Permission::State]));
|
|
assert!(!g.covers(&[Permission::DocRead, Permission::State]));
|
|
}
|
|
}
|