From 9dd5535f0550fbb82ed763673eac5138764fd691 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sat, 27 Jun 2026 23:13:29 -0400 Subject: [PATCH 01/12] feat(plugin): add C ABI types and PluginManager data structures - PluginInfo (repr(C)), PluginFn/FreeStringFn type aliases - PluginManifest with plugin.toml parsing and ABI/semver validation - PluginHooks bitmask, PluginInstance, empty PluginManager shell --- Cargo.lock | 12 ++++ Cargo.toml | 2 + src/main.rs | 1 + src/plugin/ffi.rs | 18 +++++ src/plugin/manifest.rs | 156 +++++++++++++++++++++++++++++++++++++++++ src/plugin/mod.rs | 70 ++++++++++++++++++ 6 files changed, 259 insertions(+) create mode 100644 src/plugin/ffi.rs create mode 100644 src/plugin/manifest.rs create mode 100644 src/plugin/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 2a350a5..769e726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1554,6 +1554,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1882,6 +1892,8 @@ dependencies = [ "hyper", "indoc", "inquire", + "libc", + "libloading", "lightningcss", "local-ip-address", "mime_guess", diff --git a/Cargo.toml b/Cargo.toml index c0428e9..6a779b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ blake3 = "1.5" serde_json = "1.0" dirs = "6.0" rayon = "1.12.0" +libloading = "0.9" +libc = "0.2" [dev-dependencies] mockall = "0.13.1" diff --git a/src/main.rs b/src/main.rs index 2a96e38..48877f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod config; mod converter; mod fs; mod net; +mod plugin; mod schema; mod shared; mod tera_functions; diff --git a/src/plugin/ffi.rs b/src/plugin/ffi.rs new file mode 100644 index 0000000..9006419 --- /dev/null +++ b/src/plugin/ffi.rs @@ -0,0 +1,18 @@ +use std::os::raw::c_char; + +/// C ABI PluginInfo struct returned by `norgolith_plugin_init` +#[repr(C)] +pub struct PluginInfo { + /// Which version of plugin.h this .so was compiled against + pub abi_version: u32, + /// Human-readable plugin name for error messages + pub name: *const c_char, + /// Semantic version for debugging (not validation) + pub version: *const c_char, +} + +/// Function pointer type for plugin hooks +pub type PluginFn = extern "C" fn(*const c_char) -> *mut c_char; + +/// Function pointer type for freeing plugin-allocated strings +pub type FreeStringFn = extern "C" fn(*mut c_char); diff --git a/src/plugin/manifest.rs b/src/plugin/manifest.rs new file mode 100644 index 0000000..72b968e --- /dev/null +++ b/src/plugin/manifest.rs @@ -0,0 +1,156 @@ +use std::path::Path; + +use eyre::{bail, eyre, Result}; +use serde::Deserialize; + +/// Current ABI version that this norgolith core provides +pub const CORE_ABI_VERSION: u32 = 1; + +/// Default hook timeout in milliseconds +const DEFAULT_TIMEOUT_MS: u64 = 10_000; + +/// Parsed representation of a `plugin.toml` manifest +#[derive(Debug, Clone, Deserialize)] +pub struct PluginManifest { + pub plugin: PluginMetadata, + pub hooks: HookConfig, + #[serde(default)] + pub capabilities: Capabilities, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginMetadata { + /// Name of the plugin (e.g. "my-plugin") + pub name: String, + /// Version of the plugin (e.g. "0.1.0") + pub version: String, + /// Semver requirement for norgolith compatibility (e.g. ">=0.4.0") + pub norgolith: String, + /// ABI version this plugin was compiled against + pub abi: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HookConfig { + #[serde(default)] + pub pre_build: bool, + #[serde(default)] + pub post_convert: bool, + #[serde(default)] + pub post_render: bool, + #[serde(default)] + pub post_build: bool, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Capabilities { + #[serde(default)] + pub filesystem: FilesystemAccess, + #[serde(default)] + pub network: bool, +} + +#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum FilesystemAccess { + #[default] + None, + Read, + Write, + #[serde(rename = "read-write")] + ReadWrite, +} + +fn default_timeout_ms() -> u64 { + DEFAULT_TIMEOUT_MS +} + +impl HookConfig { + /// Returns a bitmask of declared hooks + /// Bits: PRE_BUILD=1, POST_CONVERT=2, POST_RENDER=4, POST_BUILD=8 + pub fn to_mask(&self) -> u32 { + let mut mask = 0u32; + if self.pre_build { + mask |= HOOK_PRE_BUILD; + } + if self.post_convert { + mask |= HOOK_POST_CONVERT; + } + if self.post_render { + mask |= HOOK_POST_RENDER; + } + if self.post_build { + mask |= HOOK_POST_BUILD; + } + mask + } + + pub fn declared_hooks(&self) -> Vec<&'static str> { + let mut hooks = Vec::new(); + if self.pre_build { + hooks.push("pre_build"); + } + if self.post_convert { + hooks.push("post_convert"); + } + if self.post_render { + hooks.push("post_render"); + } + if self.post_build { + hooks.push("post_build"); + } + hooks + } +} + +/// Plugin needs to configure itself before any file processing +pub const HOOK_PRE_BUILD: u32 = 1; +/// Plugin needs to modify HTML after Norg->HTML conversion but before Tera templating +pub const HOOK_POST_CONVERT: u32 = 2; +/// Plugin needs to modify final HTML after Tera layout is applied +pub const HOOK_POST_RENDER: u32 = 4; +/// Plugin needs to generate additional output files after all pages are rendered +pub const HOOK_POST_BUILD: u32 = 8; + +impl PluginManifest { + /// Parse a `plugin.toml` file at the given path + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| eyre!("Failed to read {}: {}", path.display(), e))?; + let manifest: PluginManifest = toml::from_str(&content) + .map_err(|e| eyre!("Failed to parse {}: {}", path.display(), e))?; + Ok(manifest) + } + + /// Validate ABI compatibility. Returns Ok(()) if compatible + pub fn validate_abi(&self) -> Result<()> { + if self.plugin.abi != CORE_ABI_VERSION { + bail!( + "ABI mismatch: plugin '{}' requires abi={}, core provides abi={}", + self.plugin.name, + self.plugin.abi, + CORE_ABI_VERSION + ); + } + Ok(()) + } + + /// Validate semver compatibility with the running norgolith version + pub fn validate_semver(&self) -> Result<()> { + let req = semver::VersionReq::parse(&self.plugin.norgolith) + .map_err(|e| eyre!("Invalid semver requirement '{}': {}", self.plugin.norgolith, e))?; + let current = semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map_err(|e| eyre!("Invalid core version: {}", e))?; + if !req.matches(¤t) { + bail!( + "Version mismatch: plugin '{}' requires norgolith {}, installed is {}", + self.plugin.name, + self.plugin.norgolith, + current + ); + } + Ok(()) + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 0000000..7f369ca --- /dev/null +++ b/src/plugin/mod.rs @@ -0,0 +1,70 @@ +#![allow(dead_code, unused_imports)] + +pub mod ffi; +pub mod manifest; + +pub use ffi::{FreeStringFn, PluginFn, PluginInfo}; +pub use manifest::{ + Capabilities, FilesystemAccess, HookConfig, PluginManifest, CORE_ABI_VERSION, + HOOK_POST_BUILD, HOOK_POST_CONVERT, HOOK_POST_RENDER, HOOK_PRE_BUILD, +}; + +/// Hooks a plugin can implement. Each is an optional C ABI function pointer +pub struct PluginHooks { + pub pre_build: Option, + pub post_convert: Option, + pub post_render: Option, + pub post_build: Option, +} + +/// A loaded plugin instance. +pub struct PluginInstance { + pub name: String, + pub version: String, + /// Keeps the `.so` loaded in memory. Dropping this unloads the library + _lib: libloading::Library, + pub hooks: PluginHooks, + pub manifest: PluginManifest, +} + +/// Manages loaded plugins and dispatches hook calls +pub struct PluginManager { + plugins: Vec, +} + +impl PluginManager { + /// Create an empty plugin manager (no plugins loaded) + pub fn new() -> Self { + Self { + plugins: Vec::new(), + } + } + + /// Iterate over loaded plugins + pub fn plugins(&self) -> impl Iterator { + self.plugins.iter() + } + + /// Number of loaded plugins + pub fn len(&self) -> usize { + self.plugins.len() + } + + /// Whether no plugins are loaded + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() + } + + /// Check if any plugin declares a given hook bit + pub fn has_hook(&self, hook_bit: u32) -> bool { + self.plugins + .iter() + .any(|p| p.manifest.hooks.to_mask() & hook_bit != 0) + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::new() + } +} From 39ea58770e4f55a719f1b8114f93c5d0a714c6d6 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sat, 27 Jun 2026 23:41:35 -0400 Subject: [PATCH 02/12] feat(plugin): add plugin loading and validation - PluginManager::load() scans plugins/ dir, parses plugin.toml - ABI + semver validation before loading shared library - Hook mask validation, graceful skip + warn on failures - Unit tests for empty/missing/invalid plugin dirs --- src/plugin/mod.rs | 207 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 1 deletion(-) diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 7f369ca..17252ca 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -3,12 +3,16 @@ pub mod ffi; pub mod manifest; +use std::path::{Path, PathBuf}; + pub use ffi::{FreeStringFn, PluginFn, PluginInfo}; pub use manifest::{ Capabilities, FilesystemAccess, HookConfig, PluginManifest, CORE_ABI_VERSION, HOOK_POST_BUILD, HOOK_POST_CONVERT, HOOK_POST_RENDER, HOOK_PRE_BUILD, }; +use tracing::{info, warn}; + /// Hooks a plugin can implement. Each is an optional C ABI function pointer pub struct PluginHooks { pub pre_build: Option, @@ -17,7 +21,7 @@ pub struct PluginHooks { pub post_build: Option, } -/// A loaded plugin instance. +/// A loaded plugin instance pub struct PluginInstance { pub name: String, pub version: String, @@ -40,6 +44,51 @@ impl PluginManager { } } + /// Scan `plugins/` under `site_dir` and load all valid plugins + pub fn load(site_dir: &Path) -> Self { + let mut manager = Self::new(); + let plugins_dir = site_dir.join("plugins"); + + if !plugins_dir.is_dir() { + return manager; + } + + let entries = match std::fs::read_dir(&plugins_dir) { + Ok(e) => e, + Err(e) => { + warn!("Failed to read plugins directory: {}", e); + return manager; + } + }; + + for entry in entries.filter_map(|e| e.ok()) { + if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + continue; + } + let dir = entry.path(); + match load_plugin(&dir) { + Ok(instance) => { + info!( + "Loaded plugin '{}' v{}", + instance.name, instance.version + ); + manager.plugins.push(instance); + } + Err(e) => { + warn!( + "Plugin '{}' skipped: {}", + dir.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?"), + e + ); + } + } + } + + manager + } + /// Iterate over loaded plugins pub fn plugins(&self) -> impl Iterator { self.plugins.iter() @@ -68,3 +117,159 @@ impl Default for PluginManager { Self::new() } } + +fn library_extension() -> &'static str { + #[cfg(target_os = "linux")] + { "so" } + #[cfg(target_os = "macos")] + { "dylib" } + #[cfg(target_os = "windows")] + { "dll" } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { "so" } +} + +fn library_filename(name: &str) -> String { + // Linux/macOS convention: lib. + // Windows convention: .dll + #[cfg(target_os = "windows")] + { format!("{}.{}", name, library_extension()) } + #[cfg(not(target_os = "windows"))] + { format!("lib{}.{}", name, library_extension()) } +} + +/// Find the shared library file in a plugin directory +fn find_library(dir: &Path, name: &str) -> Option { + let expected = dir.join(library_filename(name)); + if expected.is_file() { + return Some(expected); + } + // Fallback: scan for any .so/.dylib/.dll in the directory + let ext = library_extension(); + std::fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .find(|e| { + e.path() + .extension() + .and_then(|s| s.to_str()) + .map(|s| s == ext) + .unwrap_or(false) + }) + .map(|e| e.path()) +} + +/// Load a single plugin from a directory containing `plugin.toml` + shared library +fn load_plugin(dir: &Path) -> eyre::Result { + let manifest_path = dir.join("plugin.toml"); + if !manifest_path.is_file() { + eyre::bail!("no plugin.toml found"); + } + + let manifest = PluginManifest::load(&manifest_path)?; + manifest.validate_abi()?; + manifest.validate_semver()?; + + let lib_path = find_library(dir, &manifest.plugin.name) + .ok_or_else(|| eyre::eyre!("shared library not found"))?; + + // SAFETY: we validate ABI before loading, and the init function is the only symbol we look up + let lib = unsafe { libloading::Library::new(&lib_path) } + .map_err(|e| eyre::eyre!("failed to load {}: {}", lib_path.display(), e))?; + + type InitFn = unsafe extern "C" fn( + *mut PluginInfo, + *mut u32, + *mut [Option; 4], + ); + + let init: libloading::Symbol = unsafe { lib.get(b"norgolith_plugin_init") } + .map_err(|e| eyre::eyre!("missing symbol norgolith_plugin_init: {}", e))?; + + let mut info = PluginInfo { + abi_version: 0, + name: std::ptr::null(), + version: std::ptr::null(), + }; + let mut hook_mask = 0u32; + let mut hooks: [Option; 4] = [None, None, None, None]; + + unsafe { init(&mut info, &mut hook_mask, &mut hooks) }; + + // Validate that the returned ABI matches what the manifest claims + if info.abi_version != manifest.plugin.abi { + warn!( + "Plugin '{}' returned abi={} but manifest declares abi={}", + manifest.plugin.name, info.abi_version, manifest.plugin.abi + ); + } + + // Validate hook mask matches manifest declarations + let declared_mask = manifest.hooks.to_mask(); + if hook_mask != declared_mask { + warn!( + "Plugin '{}' hook mask mismatch: manifest declares {:#x}, plugin returned {:#x}", + manifest.plugin.name, declared_mask, hook_mask + ); + } + + let plugin_hooks = PluginHooks { + pre_build: if hook_mask & HOOK_PRE_BUILD != 0 { + hooks[0] + } else { + None + }, + post_convert: if hook_mask & HOOK_POST_CONVERT != 0 { + hooks[1] + } else { + None + }, + post_render: if hook_mask & HOOK_POST_RENDER != 0 { + hooks[2] + } else { + None + }, + post_build: if hook_mask & HOOK_POST_BUILD != 0 { + hooks[3] + } else { + None + }, + }; + + Ok(PluginInstance { + name: manifest.plugin.name.clone(), + version: manifest.plugin.version.clone(), + _lib: lib, + hooks: plugin_hooks, + manifest, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_plugins_dir() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = PluginManager::load(tmp.path()); + assert!(mgr.is_empty()); + } + + #[test] + fn test_missing_plugins_dir() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = PluginManager::load(&tmp.path().join("nonexistent")); + assert!(mgr.is_empty()); + } + + #[test] + fn test_plugin_dir_without_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("plugins").join("broken"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + // No plugin.toml -> should skip gracefully + let mgr = PluginManager::load(tmp.path()); + assert!(mgr.is_empty()); + } +} From 9546bf4fc239fd598a1c045cbde42adff2caba15 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sat, 27 Jun 2026 23:55:54 -0400 Subject: [PATCH 03/12] feat(plugin): add safety wrappers with catch_unwind, timeout, and memory management - call_hook_safe() with thread-based timeout and panic isolation - NULL return optimization, libc::free on plugin-allocated strings - parse_hook_response() and parse_status_response() for JSON handling - C test plugins compiled via build.rs, integration tests for all paths --- build.rs | 59 ++++++++++++ src/plugin/ffi.rs | 146 +++++++++++++++++++++++++++++ src/plugin/mod.rs | 174 ++++++++++++++++++++++++++++++++++- tests/plugins/test_abort.c | 23 +++++ tests/plugins/test_error.c | 26 ++++++ tests/plugins/test_null.c | 22 +++++ tests/plugins/test_ok.c | 40 ++++++++ tests/plugins/test_timeout.c | 23 +++++ 8 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 tests/plugins/test_abort.c create mode 100644 tests/plugins/test_error.c create mode 100644 tests/plugins/test_null.c create mode 100644 tests/plugins/test_ok.c create mode 100644 tests/plugins/test_timeout.c diff --git a/build.rs b/build.rs index 569058b..5e758fa 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,5 @@ +use std::env; +use std::path::PathBuf; use std::process::Command; fn main() { @@ -6,6 +8,11 @@ fn main() { let version = get_version(); println!("cargo:rustc-env=LITH_VERSION={}", version); + + // The overhead of compiling test plugins without a compilation feature flag + // is small enough not to care about it. I cannot be bothered to add '--features test-plugins' + // to the 'cargo nextest run' command arguments every time and complaining when I forget it + compile_test_plugins(); } fn get_version() -> String { @@ -35,3 +42,55 @@ fn get_version() -> String { format!("{}+{}", cargo_version, hash) } } + +fn compile_test_plugins() { + let plugins_dir = PathBuf::from("tests/plugins"); + if !plugins_dir.is_dir() { + return; + } + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let target_dir = out_dir.parent().unwrap().parent().unwrap(); // target/debug or target/release + + let cc = "cc"; + + let entries = match std::fs::read_dir(&plugins_dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("c") { + continue; + } + + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap(); + let lib_name = if cfg!(target_os = "macos") { + format!("lib{}.dylib", stem) + } else { + format!("lib{}.so", stem) + }; + + let out_path = target_dir.join(&lib_name); + + let mut cmd = Command::new(cc); + cmd.arg("-shared") + .arg("-fPIC") + .arg("-o") + .arg(&out_path) + .arg(&path); + + #[cfg(target_os = "macos")] + cmd.arg("-undefined").arg("dynamic_lookup"); + + let status = cmd.status(); + if status.as_ref().map(|s| !s.success()).unwrap_or(true) { + // Non-fatal: test plugins are optional + println!( + "cargo:warning=failed to compile test plugin {}: {:?}", + stem, status + ); + } + } +} diff --git a/src/plugin/ffi.rs b/src/plugin/ffi.rs index 9006419..ea60906 100644 --- a/src/plugin/ffi.rs +++ b/src/plugin/ffi.rs @@ -1,4 +1,16 @@ +use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use std::panic::AssertUnwindSafe; +use std::sync::mpsc; +use std::time::Duration; + +use eyre::{bail, Result}; + +/// Wrapper to make raw pointer results Send-safe across threads +struct HookResult(Option<*mut c_char>); + +// SAFETY: we only send the pointer across threads, actual deref happens on the receiving side +unsafe impl Send for HookResult {} /// C ABI PluginInfo struct returned by `norgolith_plugin_init` #[repr(C)] @@ -16,3 +28,137 @@ pub type PluginFn = extern "C" fn(*const c_char) -> *mut c_char; /// Function pointer type for freeing plugin-allocated strings pub type FreeStringFn = extern "C" fn(*mut c_char); + +/// Call a plugin hook with catch_unwind + thread timeout +/// +/// Returns `Ok(None)` if the plugin returned NULL (no change) +/// Returns `Ok(Some(json))` if the plugin returned modified content +/// Returns `Err(msg)` on panic, timeout, or invalid output +pub fn call_hook_safe( + f: PluginFn, + input: &str, + timeout: Duration, +) -> Result> { + let c_input = CString::new(input) + .map_err(|e| eyre::eyre!("failed to create CString: {}", e))?; + + let (tx, rx) = mpsc::channel(); + + std::thread::spawn(move || { + let result = std::panic::catch_unwind(AssertUnwindSafe(|| { + HookResult(Some(f(c_input.as_ptr()))) + })); + let _ = tx.send(result); + }); + + match rx.recv_timeout(timeout) { + Ok(Ok(HookResult(ptr))) => { + let ptr = ptr.unwrap(); + if ptr.is_null() { + return Ok(None); + } + let result = unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + unsafe { libc::free(ptr as *mut libc::c_void) }; + Ok(Some(result)) + } + Ok(Err(panic)) => { + let msg = match panic.downcast_ref::<&str>() { + Some(s) => s.to_string(), + None => match panic.downcast_ref::() { + Some(s) => s.clone(), + None => "unknown panic".to_string(), + }, + }; + Err(eyre::eyre!("plugin panicked: {}", msg)) + } + Err(_timeout) => { + Err(eyre::eyre!( + "plugin hook timed out after {}ms", + timeout.as_millis() + )) + } + } +} + +/// Parse a hook response JSON and extract the HTML field +/// +/// Returns `Ok(None)` if html is null (no change) +/// Returns `Ok(Some(html))` if html is present +/// Returns `Err` on error status or invalid JSON +pub fn parse_hook_response(json: &str) -> Result> { + let val: serde_json::Value = serde_json::from_str(json) + .map_err(|e| eyre::eyre!("invalid JSON from plugin: {}", e))?; + + if let Some(status) = val.get("status").and_then(|v| v.as_str()) { + if status == "error" { + let msg = val + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + bail!("plugin error: {}", msg); + } + } + + match val.get("html").and_then(|v| v.as_str()) { + Some(html) => Ok(Some(html.to_string())), + None => Ok(None), + } +} + +/// Parse a hook response for pre_build/post_build (status only) +pub fn parse_status_response(json: &str) -> Result<()> { + let val: serde_json::Value = serde_json::from_str(json) + .map_err(|e| eyre::eyre!("invalid JSON from plugin: {}", e))?; + + if let Some(status) = val.get("status").and_then(|v| v.as_str()) { + if status == "error" { + let msg = val + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + bail!("plugin error: {}", msg); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_hook_response_with_html() { + let json = r#"{"html": "

Hello

"}"#; + assert_eq!( + parse_hook_response(json).unwrap(), + Some("

Hello

".to_string()) + ); + } + + #[test] + fn test_parse_hook_response_null_html() { + let json = r#"{"html": null}"#; + assert_eq!(parse_hook_response(json).unwrap(), None); + } + + #[test] + fn test_parse_hook_response_error() { + let json = r#"{"status": "error", "message": "something broke"}"#; + assert!(parse_hook_response(json).is_err()); + } + + #[test] + fn test_parse_status_response_ok() { + assert!(parse_status_response(r#"{"status": "ok"}"#).is_ok()); + } + + #[test] + fn test_parse_status_response_error() { + assert!(parse_status_response( + r#"{"status": "error", "message": "init failed"}"# + ) + .is_err()); + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 17252ca..64c4839 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -4,6 +4,7 @@ pub mod ffi; pub mod manifest; use std::path::{Path, PathBuf}; +use std::time::Duration; pub use ffi::{FreeStringFn, PluginFn, PluginInfo}; pub use manifest::{ @@ -29,6 +30,30 @@ pub struct PluginInstance { _lib: libloading::Library, pub hooks: PluginHooks, pub manifest: PluginManifest, + /// Function to free strings allocated by this plugin (defaults to libc::free) + pub free_string: FreeStringFn, +} + +impl PluginInstance { + /// Call a hook on this plugin with safety wrappers (catch_unwind + timeout) + pub fn call_hook(&self, f: PluginFn, input: &str) -> Option { + let timeout = Duration::from_millis(self.manifest.timeout_ms); + match ffi::call_hook_safe(f, input, timeout) { + Ok(Some(json)) => match ffi::parse_hook_response(&json) { + Ok(Some(html)) => Some(html), + Ok(None) => None, + Err(e) => { + warn!("Plugin '{}' returned invalid response: {}", self.name, e); + None + } + }, + Ok(None) => None, + Err(e) => { + warn!("Plugin '{}' hook failed: {}", self.name, e); + None + } + } + } } /// Manages loaded plugins and dispatches hook calls @@ -159,6 +184,11 @@ fn find_library(dir: &Path, name: &str) -> Option { .map(|e| e.path()) } +/// Default free function matching libc::free signature +extern "C" fn default_free(ptr: *mut std::os::raw::c_char) { + unsafe { libc::free(ptr as *mut libc::c_void) } +} + /// Load a single plugin from a directory containing `plugin.toml` + shared library fn load_plugin(dir: &Path) -> eyre::Result { let manifest_path = dir.join("plugin.toml"); @@ -242,6 +272,7 @@ fn load_plugin(dir: &Path) -> eyre::Result { _lib: lib, hooks: plugin_hooks, manifest, + free_string: default_free, }) } @@ -249,6 +280,35 @@ fn load_plugin(dir: &Path) -> eyre::Result { mod tests { use super::*; + fn target_debug() -> PathBuf { + let out_dir = PathBuf::from(env!("OUT_DIR")); + out_dir.parent().unwrap().parent().unwrap().to_path_buf() + } + + fn write_test_manifest(dir: &Path, name: &str) { + let manifest = format!( + r#"[plugin] +name = "{name}" +version = "0.1.0" +norgolith = ">=0.4.0" +abi = 1 + +[hooks] +pre_build = false +post_convert = false +post_render = true +post_build = false + +[capabilities] +filesystem = "none" +network = false + +timeout_ms = 5000 +"# + ); + std::fs::write(dir.join("plugin.toml"), manifest).unwrap(); + } + #[test] fn test_empty_plugins_dir() { let tmp = tempfile::tempdir().unwrap(); @@ -268,8 +328,120 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let plugin_dir = tmp.path().join("plugins").join("broken"); std::fs::create_dir_all(&plugin_dir).unwrap(); - // No plugin.toml -> should skip gracefully let mgr = PluginManager::load(tmp.path()); assert!(mgr.is_empty()); } + + #[test] + fn test_load_ok_plugin() { + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("plugins").join("test-ok"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_test_manifest(&plugin_dir, "test-ok"); + + let lib_name = library_filename("test-ok"); + let src = target_debug().join(&lib_name); + if !src.is_file() { + eprintln!("test plugin not compiled, skipping"); + return; + } + std::fs::copy(&src, plugin_dir.join(&lib_name)).unwrap(); + + let mgr = PluginManager::load(tmp.path()); + assert_eq!(mgr.len(), 1); + let p = mgr.plugins().next().unwrap(); + assert_eq!(p.name, "test-ok"); + assert!(p.hooks.post_render.is_some()); + } + + #[test] + fn test_hook_ok_transform() { + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("plugins").join("test-ok"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_test_manifest(&plugin_dir, "test-ok"); + + let lib_name = library_filename("test-ok"); + let src = target_debug().join(&lib_name); + if !src.is_file() { + eprintln!("test plugin not compiled, skipping"); + return; + } + std::fs::copy(&src, plugin_dir.join(&lib_name)).unwrap(); + + let mgr = PluginManager::load(tmp.path()); + let p = mgr.plugins().next().unwrap(); + let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; + let result = p.call_hook(p.hooks.post_render.unwrap(), input); + assert!(result.is_some()); + assert!(result.unwrap().contains("[transformed]")); + } + + #[test] + fn test_hook_null_returns_none() { + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("plugins").join("test-null"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_test_manifest(&plugin_dir, "test-null"); + + let lib_name = library_filename("test-null"); + let src = target_debug().join(&lib_name); + if !src.is_file() { + eprintln!("test plugin not compiled, skipping"); + return; + } + std::fs::copy(&src, plugin_dir.join(&lib_name)).unwrap(); + + let mgr = PluginManager::load(tmp.path()); + let p = mgr.plugins().next().unwrap(); + let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; + let result = p.call_hook(p.hooks.post_render.unwrap(), input); + assert_eq!(result, None); + } + + #[test] + fn test_hook_timeout() { + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("plugins").join("test-timeout"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_test_manifest(&plugin_dir, "test-timeout"); + + let lib_name = library_filename("test-timeout"); + let src = target_debug().join(&lib_name); + if !src.is_file() { + eprintln!("test plugin not compiled, skipping"); + return; + } + std::fs::copy(&src, plugin_dir.join(&lib_name)).unwrap(); + + let mgr = PluginManager::load(tmp.path()); + let p = mgr.plugins().next().unwrap(); + let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; + // Should return None (timeout is logged as warning, hook returns None) + let result = p.call_hook(p.hooks.post_render.unwrap(), input); + assert_eq!(result, None); + } + + #[test] + fn test_hook_error_response() { + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("plugins").join("test-error"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_test_manifest(&plugin_dir, "test-error"); + + let lib_name = library_filename("test-error"); + let src = target_debug().join(&lib_name); + if !src.is_file() { + eprintln!("test plugin not compiled, skipping"); + return; + } + std::fs::copy(&src, plugin_dir.join(&lib_name)).unwrap(); + + let mgr = PluginManager::load(tmp.path()); + let p = mgr.plugins().next().unwrap(); + let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; + // Error response -> call_hook returns None (warning logged) + let result = p.call_hook(p.hooks.post_render.unwrap(), input); + assert_eq!(result, None); + } } diff --git a/tests/plugins/test_abort.c b/tests/plugins/test_abort.c new file mode 100644 index 0000000..3cc8ec5 --- /dev/null +++ b/tests/plugins/test_abort.c @@ -0,0 +1,23 @@ +#include + +typedef struct { + unsigned int abi_version; + const char* name; + const char* version; +} PluginInfo; + +typedef char* (*PluginFn)(const char*); + +// Simulates a crash (abort is not catchable by catch_unwind, but tests the path) +char* hook_post_render(const char* input_json) { + abort(); + return NULL; +} + +void norgolith_plugin_init(PluginInfo* info, unsigned int* hook_mask, PluginFn hooks[4]) { + info->abi_version = 1; + info->name = "test-abort"; + info->version = "0.1.0"; + *hook_mask = 4; // POST_RENDER + hooks[2] = hook_post_render; +} diff --git a/tests/plugins/test_error.c b/tests/plugins/test_error.c new file mode 100644 index 0000000..057730e --- /dev/null +++ b/tests/plugins/test_error.c @@ -0,0 +1,26 @@ +#include +#include + +typedef struct { + unsigned int abi_version; + const char* name; + const char* version; +} PluginInfo; + +typedef char* (*PluginFn)(const char*); + +// Returns an error JSON +char* hook_post_render(const char* input_json) { + const char* err = "{\"status\":\"error\",\"message\":\"intentional test error\"}"; + char* result = malloc(strlen(err) + 1); + strcpy(result, err); + return result; +} + +void norgolith_plugin_init(PluginInfo* info, unsigned int* hook_mask, PluginFn hooks[4]) { + info->abi_version = 1; + info->name = "test-error"; + info->version = "0.1.0"; + *hook_mask = 4; // POST_RENDER + hooks[2] = hook_post_render; +} diff --git a/tests/plugins/test_null.c b/tests/plugins/test_null.c new file mode 100644 index 0000000..6e92f19 --- /dev/null +++ b/tests/plugins/test_null.c @@ -0,0 +1,22 @@ +#include + +typedef struct { + unsigned int abi_version; + const char* name; + const char* version; +} PluginInfo; + +typedef char* (*PluginFn)(const char*); + +// Returns NULL (no change) +char* hook_post_render(const char* input_json) { + return NULL; +} + +void norgolith_plugin_init(PluginInfo* info, unsigned int* hook_mask, PluginFn hooks[4]) { + info->abi_version = 1; + info->name = "test-null"; + info->version = "0.1.0"; + *hook_mask = 4; // POST_RENDER + hooks[2] = hook_post_render; +} diff --git a/tests/plugins/test_ok.c b/tests/plugins/test_ok.c new file mode 100644 index 0000000..93e890f --- /dev/null +++ b/tests/plugins/test_ok.c @@ -0,0 +1,40 @@ +#include +#include + +typedef struct { + unsigned int abi_version; + const char* name; + const char* version; +} PluginInfo; + +typedef char* (*PluginFn)(const char*); + +// post_render hook: echoes input back with " [transformed]" appended to html +char* hook_post_render(const char* input_json) { + // Find "html" field value (simple parse for testing) + const char* html_start = strstr(input_json, "\"html\":\""); + if (!html_start) return NULL; + html_start += 8; + + const char* html_end = strchr(html_start, '"'); + if (!html_end) return NULL; + + size_t html_len = html_end - html_start; + size_t suffix_len = strlen(" [transformed]"); + + char* result = malloc(html_len + suffix_len + 34); // room for JSON wrapper + memcpy(result, "{\"html\":\"", 9); + memcpy(result + 9, html_start, html_len); + memcpy(result + 9 + html_len, " [transformed]\"", 16); + result[9 + html_len + 15] = '}'; + result[9 + html_len + 16] = '\0'; + return result; +} + +void norgolith_plugin_init(PluginInfo* info, unsigned int* hook_mask, PluginFn hooks[4]) { + info->abi_version = 1; + info->name = "test-ok"; + info->version = "0.1.0"; + *hook_mask = 4; // POST_RENDER = bit 2 + hooks[2] = hook_post_render; +} diff --git a/tests/plugins/test_timeout.c b/tests/plugins/test_timeout.c new file mode 100644 index 0000000..baa707e --- /dev/null +++ b/tests/plugins/test_timeout.c @@ -0,0 +1,23 @@ +#include + +typedef struct { + unsigned int abi_version; + const char* name; + const char* version; +} PluginInfo; + +typedef char* (*PluginFn)(const char*); + +// Infinite loop to trigger timeout +char* hook_post_render(const char* input_json) { + while (1) {} + return NULL; +} + +void norgolith_plugin_init(PluginInfo* info, unsigned int* hook_mask, PluginFn hooks[4]) { + info->abi_version = 1; + info->name = "test-timeout"; + info->version = "0.1.0"; + *hook_mask = 4; // POST_RENDER + hooks[2] = hook_post_render; +} From aed2956be03e718b40bd5865e55c372f457b3d29 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 00:45:02 -0400 Subject: [PATCH 04/12] feat(plugin): add Landlock sandbox for filesystem confinement - sandbox-linux feature flag (default off), landlock 0.4 on Linux only - apply_landlock() with runtime detection and graceful degradation - Restricts FS to site, public, plugins, cache directories --- Cargo.lock | 32 ++++++++++++ Cargo.toml | 4 ++ src/plugin/mod.rs | 1 + src/plugin/sandbox.rs | 119 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 src/plugin/sandbox.rs diff --git a/Cargo.lock b/Cargo.lock index 769e726..1fbb9cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,6 +773,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1522,6 +1542,17 @@ dependencies = [ "libc", ] +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1892,6 +1923,7 @@ dependencies = [ "hyper", "indoc", "inquire", + "landlock", "libc", "libloading", "lightningcss", diff --git a/Cargo.toml b/Cargo.toml index 6a779b6..2a5499b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,3 +69,7 @@ tempfile = "3.17.1" [features] ci = [] # Used to ignore certain tests on GitHub CIs +sandbox-linux = ["landlock"] + +[target.x86_64-unknown-linux-gnu.dependencies] +landlock = { version = "0.4", optional = true } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 64c4839..1b2b450 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -2,6 +2,7 @@ pub mod ffi; pub mod manifest; +pub mod sandbox; use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/src/plugin/sandbox.rs b/src/plugin/sandbox.rs new file mode 100644 index 0000000..3a9b52f --- /dev/null +++ b/src/plugin/sandbox.rs @@ -0,0 +1,119 @@ +use std::path::Path; + +use eyre::Result; +use tracing::warn; + +/// Apply Landlock filesystem restrictions to the current process +/// +/// Restricts access to: +/// - `site_dir` (read/write) +/// - `site_dir/public` (write output) +/// - `site_dir/plugins` (read .so files) +/// - Cache directory (read/write) +/// +/// Once applied, restrictions are irreversible for the process lifetime. +/// On non-Linux platforms or without `sandbox-linux` feature, this is a no-op. +pub fn apply_landlock(site_dir: &Path) -> Result<()> { + #[cfg(not(all(target_os = "linux", feature = "sandbox-linux")))] + { + let _ = site_dir; + Ok(()) + } + + #[cfg(all(target_os = "linux", feature = "sandbox-linux"))] + { + if !landlock_available() { + warn!( + "Landlock unavailable (kernel too old?). \ + Plugins running without filesystem confinement." + ); + return Ok(()); + } + + use landlock::*; + + let public_dir = site_dir.join("public"); + let plugins_dir = site_dir.join("plugins"); + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| std::env::temp_dir()) + .join("norgolith"); + + // Ensure dirs exist for path_beneath_rules (PathFd::new requires the path) + let _ = std::fs::create_dir_all(&public_dir); + let _ = std::fs::create_dir_all(&plugins_dir); + let _ = std::fs::create_dir_all(&cache_dir); + + let allowed_paths: Vec<&Path> = vec![ + site_dir, + &public_dir, + &plugins_dir, + &cache_dir, + ]; + + let access = AccessFs::from_all(ABI::V1); + + let ruleset = Ruleset::default() + .handle_access(access) + .map_err(|e| eyre::eyre!("failed to create landlock ruleset: {}", e))? + .create() + .map_err(|e| eyre::eyre!("failed to create landlock ruleset: {}", e))?; + + let rules = path_beneath_rules(&allowed_paths, access); + + let ruleset = ruleset + .add_rules(rules) + .map_err(|e| eyre::eyre!("failed to add landlock rules: {}", e))?; + + match ruleset.restrict_self() { + Ok(status) => { + if status.ruleset != RulesetStatus::FullyEnforced { + warn!( + "Landlock partially enforced (kernel feature gap). \ + Some restrictions may not apply." + ); + } + } + Err(e) => { + warn!( + "Failed to apply Landlock restrictions: {}. \ + Plugins running without filesystem confinement.", + e + ); + } + } + + Ok(()) + } +} + +/// Check if Landlock is available on this system +#[cfg(all(target_os = "linux", feature = "sandbox-linux"))] +fn landlock_available() -> bool { + // Try the landlock_create_ruleset syscall with version query + let ret = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + std::ptr::null::(), + 0usize, + 1u32, // LANDLOCK_CREATE_RULESET_VERSION + ) + }; + // ret > 0 means supported, ENOSYS means not supported + if ret > 0 { + return true; + } + let errno = unsafe { *libc::__errno_location() }; + errno != libc::ENOSYS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_landlock_no_panic() { + let tmp = tempfile::tempdir().unwrap(); + // Should not panic, even if Landlock is unavailable + let _ = apply_landlock(tmp.path()); + } +} From 6908709e201f30119d0fd9d985b701a99f58fcc8 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 00:58:41 -0400 Subject: [PATCH 05/12] feat(plugin): wire hook points into build pipeline - pre_build/post_build in build.rs bookend the full build - post_convert/post_render in build_content_entry modify per-page HTML - dev.rs: PluginManager in ServerState, hooks in render_all_pages - sandbox: fix redundant closure in unwrap_or_else --- src/cmd/build.rs | 99 ++++++++++++++++++++++++++++++++++++++++--- src/cmd/dev.rs | 74 ++++++++++++++++++++++++++++++-- src/plugin/sandbox.rs | 2 +- 3 files changed, 166 insertions(+), 9 deletions(-) diff --git a/src/cmd/build.rs b/src/cmd/build.rs index d588147..84fec90 100644 --- a/src/cmd/build.rs +++ b/src/cmd/build.rs @@ -17,7 +17,7 @@ fn href_root_re() -> &'static regex::Regex { RE.get_or_init(|| regex::Regex::new(r#"href="(/|/)"#).expect("valid regex")) } -use crate::{cache::BuildCache, config, fs, shared}; +use crate::{cache::BuildCache, config, fs, plugin, shared}; /// Represents the directory structure of a Norgolith site. /// @@ -188,7 +188,8 @@ fn generate_xml_feeds( /// * `paths` - Site directory paths /// * `site_config` - Site configuration /// * `minify` - Enable minification of output -#[instrument(level = "debug", skip(tera, paths, site_config, shared_context, cache))] +#[allow(clippy::too_many_arguments)] +#[instrument(level = "debug", skip(tera, paths, site_config, shared_context, cache, plugin_mgr))] fn build_contents( tera: &Tera, paths: &SitePaths, @@ -197,6 +198,7 @@ fn build_contents( shared_context: &Context, cache: &mut BuildCache, minify: bool, + plugin_mgr: &plugin::PluginManager, ) -> Result<(usize, BuildTimings)> { use rayon::prelude::*; @@ -225,6 +227,7 @@ fn build_contents( minify, shared_context, cache, + plugin_mgr, ) }) .collect(); @@ -274,7 +277,7 @@ type BuildResult = Result)>>; #[allow(clippy::too_many_arguments)] #[instrument( level = "debug", - skip(tera, paths, site_config, shared_context, cache) + skip(tera, paths, site_config, shared_context, cache, plugin_mgr) )] fn build_content_entry( path: &Path, @@ -284,6 +287,7 @@ fn build_content_entry( minify: bool, shared_context: &Context, cache: &BuildCache, + plugin_mgr: &plugin::PluginManager, ) -> BuildResult { let rel_path = path .strip_prefix(&paths.content) @@ -325,7 +329,7 @@ fn build_content_entry( let cached = cache.get(&cache_key, &content); // Load (parse_tree + HTML on miss, deserialization on hit) - let (metadata, cache_insert) = if let Some(cached) = cached { + let (mut metadata, cache_insert) = if let Some(cached) = cached { match serde_json::from_value::(cached.clone()) { Ok(md) => (md, None), Err(_) => { @@ -340,12 +344,58 @@ fn build_content_entry( (md, Some((cache_key, content.clone(), cache_val))) }; + // post_convert hook: modify HTML after Norg conversion, before Tera + if plugin_mgr.has_hook(plugin::HOOK_POST_CONVERT) { + if let Some(html) = metadata.get("raw").and_then(|v| v.as_str()) { + let input = serde_json::json!({ + "html": html, + "metadata": metadata, + "rel_path": rel_path.to_string_lossy(), + }) + .to_string(); + for p in plugin_mgr.plugins() { + if let Some(f) = p.hooks.post_convert { + if let Some(new_html) = p.call_hook(f, &input) { + if let Ok(val) = serde_json::from_str::(&new_html) { + if let Some(s) = val.get("html").and_then(|v| v.as_str()) { + if let toml::Value::Table(ref mut table) = metadata { + table.insert("raw".to_string(), toml::Value::String(s.to_string())); + } + } + } + } + } + } + } + } + // Determine output path let public_path = determine_public_path(&paths.public, rel_path)?; // Template render let mut rendered = shared::render_norg_page(tera, &metadata, shared_context)?; + // post_render hook: modify final HTML after Tera, before write + if plugin_mgr.has_hook(plugin::HOOK_POST_RENDER) { + let input = serde_json::json!({ + "html": rendered, + "metadata": metadata, + "rel_path": rel_path.to_string_lossy(), + }) + .to_string(); + for p in plugin_mgr.plugins() { + if let Some(f) = p.hooks.post_render { + if let Some(new_html) = p.call_hook(f, &input) { + if let Ok(val) = serde_json::from_str::(&new_html) { + if let Some(s) = val.get("html").and_then(|v| v.as_str()) { + rendered = s.to_string(); + } + } + } + } + } + } + // Href rewrite let href_re = href_root_re(); rendered = href_re @@ -685,6 +735,7 @@ fn copy_assets(assets_dir: &Path, target_dir: &Path, minify: bool) -> Result Result<()> { let tera = shared::init_tera(paths.templates.to_str().unwrap(), &paths.theme_templates)?; timings.tera_ms = t.elapsed().as_millis(); + // Load plugins and apply sandbox + let t = Instant::now(); + let plugin_mgr = plugin::PluginManager::load(&root_dir); + let _ = plugin::sandbox::apply_landlock(&root_dir); + timings.plugins_ms = t.elapsed().as_millis(); + + if !plugin_mgr.is_empty() { + println!( + " {} {} {} plugins", + "•".green(), + format!("{:<12}", "Plugins").bold(), + plugin_mgr.len() + ); + } + + // pre_build hook + if plugin_mgr.has_hook(plugin::HOOK_PRE_BUILD) { + let config_json = serde_json::to_string(&site_config) + .unwrap_or_default(); + for p in plugin_mgr.plugins() { + if let Some(f) = p.hooks.pre_build { + p.call_hook(f, &config_json); + } + } + } + // Prepare build directory let t = Instant::now(); prepare_build_directory(&paths.public)?; @@ -908,7 +986,7 @@ pub fn build(minify: bool) -> Result<()> { // Build content let t = Instant::now(); - let (page_count, content_timings) = build_contents(&tera, &paths, &posts, &site_config, &shared_context, &mut cache, minify)?; + let (page_count, content_timings) = build_contents(&tera, &paths, &posts, &site_config, &shared_context, &mut cache, minify, &plugin_mgr)?; timings.content_ms = t.elapsed().as_millis(); timings.page_count = page_count; // Copy per-page sub-timings from the concurrent build @@ -966,6 +1044,17 @@ pub fn build(minify: bool) -> Result<()> { shared::get_elapsed_time(t).dimmed() ); + // post_build hook + if plugin_mgr.has_hook(plugin::HOOK_POST_BUILD) { + let config_json = serde_json::to_string(&site_config) + .unwrap_or_default(); + for p in plugin_mgr.plugins() { + if let Some(f) = p.hooks.post_build { + p.call_hook(f, &config_json); + } + } + } + println!(); let total_ms = build_start.elapsed().as_millis(); println!( diff --git a/src/cmd/dev.rs b/src/cmd/dev.rs index e9f34b9..3f264c5 100644 --- a/src/cmd/dev.rs +++ b/src/cmd/dev.rs @@ -27,7 +27,7 @@ use tokio_tungstenite::accept_async; use tracing::{debug, error, info, instrument, warn}; use walkdir::WalkDir; -use crate::{config, fs, shared}; +use crate::{config, fs, plugin, shared}; /// Represents the directory structure of a Norgolith site. /// @@ -88,6 +88,7 @@ struct ServerState { posts: Arc>>, cache: Arc>, rendered_pages: Arc>>, + plugin_mgr: Arc, } impl ServerState { @@ -160,7 +161,7 @@ impl ServerState { let posts = self.posts.read().await.clone(); let cache = self.cache.read().await; - match render_all_pages(&tera, &self.paths, &config, &self.routes_url, &posts, &cache) { + match render_all_pages(&tera, &self.paths, &config, &self.routes_url, &posts, &cache, &self.plugin_mgr) { Ok(new_pages) => { let mut pages = self.rendered_pages.write().await; *pages = new_pages; @@ -1076,6 +1077,7 @@ async fn handle_server_request( /// Walks the content directory, renders each .norg file through the Tera template /// pipeline, and stores the HTML indexed by URL path. Also pre-renders category /// pages and XML feed templates. +#[allow(clippy::too_many_arguments)] fn render_all_pages( tera: &Tera, paths: &SitePaths, @@ -1083,6 +1085,7 @@ fn render_all_pages( routes_url: &str, posts: &[toml::Value], cache: &crate::cache::BuildCache, + plugin_mgr: &plugin::PluginManager, ) -> Result> { let mut pages = HashMap::new(); @@ -1117,7 +1120,7 @@ fn render_all_pages( // Full load with HTML conversion (reuse build_cache if available) let cache_key = rel_path.with_extension(""); - let metadata = if let Some(cached) = cache.get(&cache_key, &content) { + let mut metadata = if let Some(cached) = cache.get(&cache_key, &content) { serde_json::from_value(cached).unwrap_or_else(|_| { shared::load_metadata_from_content(&content, rel_path, routes_url) }) @@ -1125,8 +1128,54 @@ fn render_all_pages( shared::load_metadata_from_content(&content, rel_path, routes_url) }; + // post_convert hook: modify HTML after Norg conversion, before Tera + if plugin_mgr.has_hook(plugin::HOOK_POST_CONVERT) { + if let Some(html) = metadata.get("raw").and_then(|v| v.as_str()) { + let input = serde_json::json!({ + "html": html, + "metadata": metadata, + "rel_path": rel_path.to_string_lossy(), + }) + .to_string(); + for p in plugin_mgr.plugins() { + if let Some(f) = p.hooks.post_convert { + if let Some(new_html) = p.call_hook(f, &input) { + if let Ok(val) = serde_json::from_str::(&new_html) { + if let Some(s) = val.get("html").and_then(|v| v.as_str()) { + if let toml::Value::Table(ref mut table) = metadata { + table.insert("raw".to_string(), toml::Value::String(s.to_string())); + } + } + } + } + } + } + } + } + let mut body = shared::render_norg_page(tera, &metadata, &shared_context)?; + // post_render hook: modify final HTML after Tera, before URL rewrite + if plugin_mgr.has_hook(plugin::HOOK_POST_RENDER) { + let input = serde_json::json!({ + "html": body, + "metadata": metadata, + "rel_path": rel_path.to_string_lossy(), + }) + .to_string(); + for p in plugin_mgr.plugins() { + if let Some(f) = p.hooks.post_render { + if let Some(new_html) = p.call_hook(f, &input) { + if let Ok(val) = serde_json::from_str::(&new_html) { + if let Some(s) = val.get("html").and_then(|v| v.as_str()) { + body = s.to_string(); + } + } + } + } + } + } + // Always use the proper URL to the development server for template links that refers // to the local URL, this is useful when running the server exposed to LAN network body = body.replace( @@ -1262,6 +1311,23 @@ async fn setup_server_state( // Open build cache for incremental renders let cache = crate::cache::BuildCache::open(&root_dir)?; + // Load plugins, apply sandbox, run pre_build hook + let plugin_mgr = plugin::PluginManager::load(&root_dir); + let _ = plugin::sandbox::apply_landlock(&root_dir); + if plugin_mgr.has_hook(plugin::HOOK_PRE_BUILD) { + let input = serde_json::json!({ + "site_config": site_config, + "pages_dir": paths.content, + "output_dir": root_dir.join("public"), + }) + .to_string(); + for p in plugin_mgr.plugins() { + if let Some(f) = p.hooks.pre_build { + p.call_hook(f, &input); + } + } + } + // Pre-render all pages into memory for instant serving let rendered_pages = render_all_pages( &tera, @@ -1270,6 +1336,7 @@ async fn setup_server_state( &routes_url, &posts, &cache, + &plugin_mgr, )?; let tera = Arc::new(RwLock::new(tera)); @@ -1284,6 +1351,7 @@ async fn setup_server_state( posts: Arc::new(RwLock::new(posts)), cache: Arc::new(RwLock::new(cache)), rendered_pages: Arc::new(RwLock::new(rendered_pages)), + plugin_mgr: Arc::new(plugin_mgr), })) } diff --git a/src/plugin/sandbox.rs b/src/plugin/sandbox.rs index 3a9b52f..398b0be 100644 --- a/src/plugin/sandbox.rs +++ b/src/plugin/sandbox.rs @@ -35,7 +35,7 @@ pub fn apply_landlock(site_dir: &Path) -> Result<()> { let public_dir = site_dir.join("public"); let plugins_dir = site_dir.join("plugins"); let cache_dir = dirs::cache_dir() - .unwrap_or_else(|| std::env::temp_dir()) + .unwrap_or_else(std::env::temp_dir) .join("norgolith"); // Ensure dirs exist for path_beneath_rules (PathFd::new requires the path) From f9dc9a20c457bcf54d5ebf92943134b59d611c8b Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 16:16:38 -0400 Subject: [PATCH 06/12] feat(plugin): add CLI commands for plugin management - list: show loaded plugins with name, version, hooks, status - new: scaffold plugin dir with Cargo.toml, plugin.toml, src/lib.rs - install: validate manifest, cargo build --release, copy .so + manifest - uninstall: remove plugin directory - make library_filename/library_extension pub for install command --- src/cli.rs | 10 ++ src/cmd/mod.rs | 3 + src/cmd/plugin.rs | 270 ++++++++++++++++++++++++++++++++++++++++++++++ src/plugin/mod.rs | 4 +- 4 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/cmd/plugin.rs diff --git a/src/cli.rs b/src/cli.rs index 92a9c85..e3b8cca 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -132,6 +132,11 @@ enum Commands { #[arg(long = "no-minify")] _no_minify: bool, }, + /// Plugin management + Plugin { + #[command(subcommand)] + subcommand: cmd::PluginCommands, + }, /// Preview from build result Preview { #[arg(short = 'p', long, default_value_t = 3030, help = "Port to be used")] @@ -185,6 +190,7 @@ pub async fn start() -> Result<()> { minify: _, _no_minify, } => build_site(!_no_minify).await?, + Commands::Plugin { subcommand } => plugin_handle(&subcommand)?, Commands::New { kind, name, @@ -248,6 +254,10 @@ async fn theme_handle(subcommand: &cmd::ThemeCommands) -> Result<()> { cmd::theme(subcommand).await } +fn plugin_handle(subcommand: &cmd::PluginCommands) -> Result<()> { + cmd::plugin(subcommand) +} + /// Creates a new asset with the given kind and name. /// /// # Arguments diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 8be10cc..188948d 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -2,6 +2,7 @@ mod build; mod dev; mod init; mod new; +mod plugin; mod preview; mod theme; @@ -9,6 +10,8 @@ pub use build::build; pub use dev::dev; pub use init::init; pub use new::new; +pub use plugin::handle as plugin; +pub use plugin::PluginCommands; pub use preview::preview; pub use theme::handle as theme; pub use theme::ThemeCommands; diff --git a/src/cmd/plugin.rs b/src/cmd/plugin.rs new file mode 100644 index 0000000..3d955d1 --- /dev/null +++ b/src/cmd/plugin.rs @@ -0,0 +1,270 @@ +use std::path::{Path, PathBuf}; + +use clap::Subcommand; +use colored::Colorize; +use eyre::{bail, Result}; + +use crate::plugin::{self, PluginManifest, CORE_ABI_VERSION}; + +#[derive(Subcommand, Clone)] +pub enum PluginCommands { + /// List installed plugins and their status + List, + /// Scaffold a new plugin project + New { + /// Plugin name (used for directory and crate name) + name: String, + }, + /// Build and install a plugin from a local path + Install { + /// Path to the plugin directory (containing Cargo.toml) + path: PathBuf, + }, + /// Remove an installed plugin + Uninstall { + /// Plugin name to remove + name: String, + }, +} + +pub fn handle(subcommand: &PluginCommands) -> Result<()> { + match subcommand { + PluginCommands::List => list_plugins(), + PluginCommands::New { name } => new_plugin(name), + PluginCommands::Install { path } => install_plugin(path), + PluginCommands::Uninstall { name } => uninstall_plugin(name), + } +} + +fn list_plugins() -> Result<()> { + let cwd = std::env::current_dir()?; + let mgr = plugin::PluginManager::load(&cwd); + + if mgr.is_empty() { + println!("{}", "No plugins installed.".dimmed()); + println!( + "Install one with {} or scaffold a new one with {}", + "lith plugin install ".cyan(), + "lith plugin new ".cyan() + ); + return Ok(()); + } + + println!( + "{:<20} {:<10} {:<30} {}", + "NAME".bold(), + "VERSION".bold(), + "HOOKS".bold(), + "STATUS".bold() + ); + println!("{}", "-".repeat(75)); + + for p in mgr.plugins() { + let hooks = { + let mut h = Vec::new(); + if p.manifest.hooks.pre_build { + h.push("pre_build"); + } + if p.manifest.hooks.post_convert { + h.push("post_convert"); + } + if p.manifest.hooks.post_render { + h.push("post_render"); + } + if p.manifest.hooks.post_build { + h.push("post_build"); + } + if h.is_empty() { + "none".dimmed().to_string() + } else { + h.join(", ") + } + }; + + let status = "ok".green(); + println!( + "{:<20} {:<10} {:<30} {}", + p.name, p.version, hooks, status + ); + } + + println!("\n{} plugin(s) loaded", mgr.len()); + Ok(()) +} + +fn new_plugin(name: &str) -> Result<()> { + let cwd = std::env::current_dir()?; + let plugins_dir = cwd.join("plugins").join(name); + + if plugins_dir.exists() { + bail!("Plugin '{}' already exists at {}", name, plugins_dir.display()); + } + + std::fs::create_dir_all(plugins_dir.join("src"))?; + + // NOTE: I still need to handle the case where the plugin requires a dev norgolith version, e.g. (>=0.4.0-COMMIT_HASH) + // For now, just use the current version of norgolith from Cargo.toml. I'll need to figure out if semver crate can + // handle this case, or if I need to implement a custom version comparison for dev versions. + const NORGOLITH_VERSION: &str = env!("CARGO_PKG_VERSION"); + + // plugin.toml + let manifest = format!( + r#"[plugin] +name = "{name}" +version = "0.1.0" +norgolith = ">={NORGOLITH_VERSION}" +abi = {CORE_ABI_VERSION} + +[hooks] +pre_build = false +post_convert = false +post_render = false +post_build = false + +[capabilities] +filesystem = "none" +network = false + +timeout_ms = 10000 +"# + ); + std::fs::write(plugins_dir.join("plugin.toml"), manifest)?; + + // Cargo.toml + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +norgolith-plugin-sdk = "0.1" +"# + ); + std::fs::write(plugins_dir.join("Cargo.toml"), cargo_toml)?; + + // src/lib.rs + let lib_rs = format!( + r#"use norgolith_plugin_sdk::*; + +register_plugin!("{name}", "0.1.0") + .on_post_render(|ctx| {{ + Ok(Some(ctx.html)) + }}) + .register(); +"# + ); + std::fs::write(plugins_dir.join("src").join("lib.rs"), lib_rs)?; + + println!( + "Plugin '{}' created at {}", + name.bold(), + plugins_dir.display() + ); + println!("\nNext steps:"); + println!(" 1. cd plugins/{}", name); + println!(" 2. Implement your hooks in src/lib.rs"); + println!(" 3. Build with `cargo build`"); + println!(" 4. Test with `lith plugin install plugins/{}'", name); + + Ok(()) +} + +fn install_plugin(source_dir: &Path) -> Result<()> { + if !source_dir.is_dir() { + bail!("Not a directory: {}", source_dir.display()); + } + + let manifest_path = source_dir.join("plugin.toml"); + if !manifest_path.is_file() { + bail!( + "No plugin.toml found in {}", + source_dir.display() + ); + } + + let manifest = PluginManifest::load(&manifest_path)?; + manifest.validate_abi()?; + manifest.validate_semver()?; + + // Build the plugin + println!("{}", "Building plugin...".dimmed()); + let status = std::process::Command::new("cargo") + .arg("build") + .arg("--release") + .current_dir(source_dir) + .status()?; + + if !status.success() { + bail!("cargo build failed"); + } + + // Find the built library + let target_dir = source_dir.join("target").join("release"); + let lib_name = plugin::library_filename(&manifest.plugin.name); + let lib_path = target_dir.join(&lib_name); + + if !lib_path.is_file() { + // Fallback: scan for any matching library + let ext = plugin::library_extension(); + let found = std::fs::read_dir(&target_dir) + .ok() + .and_then(|entries| { + entries + .filter_map(|e| e.ok()) + .find(|e| { + e.path() + .extension() + .and_then(|s| s.to_str()) + .map(|s| s == ext) + .unwrap_or(false) + }) + .map(|e| e.path()) + }); + + match found { + Some(path) => { + let cwd = std::env::current_dir()?; + let dest_dir = cwd.join("plugins").join(&manifest.plugin.name); + std::fs::create_dir_all(&dest_dir)?; + std::fs::copy(&path, dest_dir.join(path.file_name().unwrap()))?; + std::fs::copy(&manifest_path, dest_dir.join("plugin.toml"))?; + } + None => { + bail!( + "Built library not found in {}", + target_dir.display() + ); + } + } + } else { + let cwd = std::env::current_dir()?; + let dest_dir = cwd.join("plugins").join(&manifest.plugin.name); + std::fs::create_dir_all(&dest_dir)?; + std::fs::copy(&lib_path, dest_dir.join(&lib_name))?; + std::fs::copy(&manifest_path, dest_dir.join("plugin.toml"))?; + } + + println!( + "Plugin '{}' v{} installed", + manifest.plugin.name.bold(), + manifest.plugin.version + ); + Ok(()) +} + +fn uninstall_plugin(name: &str) -> Result<()> { + let cwd = std::env::current_dir()?; + let plugin_dir = cwd.join("plugins").join(name); + + if !plugin_dir.is_dir() { + bail!("Plugin '{}' is not installed", name); + } + + std::fs::remove_dir_all(&plugin_dir)?; + println!("Plugin '{}' uninstalled", name.bold()); + Ok(()) +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 1b2b450..b2739ee 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -144,7 +144,7 @@ impl Default for PluginManager { } } -fn library_extension() -> &'static str { +pub fn library_extension() -> &'static str { #[cfg(target_os = "linux")] { "so" } #[cfg(target_os = "macos")] @@ -155,7 +155,7 @@ fn library_extension() -> &'static str { { "so" } } -fn library_filename(name: &str) -> String { +pub fn library_filename(name: &str) -> String { // Linux/macOS convention: lib. // Windows convention: .dll #[cfg(target_os = "windows")] From 6f817843ac583309f3f18c4fcf47a6f62a196535 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 16:52:13 -0400 Subject: [PATCH 07/12] refactor(workspace): migrate to monorepo with core/ and sdk/ crates - workspace root Cargo.toml with members core and sdk - core/: norgolith binary, all existing code - sdk/: norgolith-plugin-sdk v0.1.0 - SDK provides PluginInfo, context types, register_plugin! macro Note: plugin sdk can be published to crates.io with no hassle, but core needs the rust-norg git dep to be published as a crate. --- Cargo.lock | 574 +-- Cargo.toml | 77 +- core/Cargo.lock | 4224 +++++++++++++++++ core/Cargo.toml | 75 + build.rs => core/build.rs | 0 {src => core/src}/cache.rs | 0 {src => core/src}/cli.rs | 0 {src => core/src}/cmd/build.rs | 0 {src => core/src}/cmd/dev.rs | 0 {src => core/src}/cmd/init.rs | 2 +- {src => core/src}/cmd/mod.rs | 0 {src => core/src}/cmd/new.rs | 0 {src => core/src}/cmd/plugin.rs | 0 {src => core/src}/cmd/preview.rs | 0 {src => core/src}/cmd/theme.rs | 0 {src => core/src}/config/mod.rs | 0 {src => core/src}/converter/html.rs | 0 {src => core/src}/converter/meta.rs | 0 {src => core/src}/converter/mod.rs | 0 {src => core/src}/fs.rs | 0 {src => core/src}/main.rs | 0 {src => core/src}/net.rs | 0 {src => core/src}/plugin/ffi.rs | 0 {src => core/src}/plugin/manifest.rs | 0 {src => core/src}/plugin/mod.rs | 0 {src => core/src}/plugin/sandbox.rs | 0 .../src}/resources/assets/livereload.js | 0 {src => core/src}/resources/assets/style.css | 0 .../src}/resources/content/index.norg | 0 .../src}/resources/templates/base.html | 0 .../src}/resources/templates/categories.html | 0 .../src}/resources/templates/category.html | 0 .../src}/resources/templates/default.html | 0 {src => core/src}/resources/templates/rss.xml | 0 {src => core/src}/schema/mod.rs | 0 {src => core/src}/schema/validator.rs | 0 {src => core/src}/shared/mod.rs | 0 {src => core/src}/tera_functions.rs | 0 {src => core/src}/theme.rs | 0 {tests => core/tests}/plugins/test_abort.c | 0 {tests => core/tests}/plugins/test_error.c | 0 {tests => core/tests}/plugins/test_null.c | 0 {tests => core/tests}/plugins/test_ok.c | 0 {tests => core/tests}/plugins/test_timeout.c | 0 sdk/Cargo.toml | 13 + sdk/src/lib.rs | 162 + 46 files changed, 4661 insertions(+), 466 deletions(-) create mode 100644 core/Cargo.lock create mode 100644 core/Cargo.toml rename build.rs => core/build.rs (100%) rename {src => core/src}/cache.rs (100%) rename {src => core/src}/cli.rs (100%) rename {src => core/src}/cmd/build.rs (100%) rename {src => core/src}/cmd/dev.rs (100%) rename {src => core/src}/cmd/init.rs (99%) rename {src => core/src}/cmd/mod.rs (100%) rename {src => core/src}/cmd/new.rs (100%) rename {src => core/src}/cmd/plugin.rs (100%) rename {src => core/src}/cmd/preview.rs (100%) rename {src => core/src}/cmd/theme.rs (100%) rename {src => core/src}/config/mod.rs (100%) rename {src => core/src}/converter/html.rs (100%) rename {src => core/src}/converter/meta.rs (100%) rename {src => core/src}/converter/mod.rs (100%) rename {src => core/src}/fs.rs (100%) rename {src => core/src}/main.rs (100%) rename {src => core/src}/net.rs (100%) rename {src => core/src}/plugin/ffi.rs (100%) rename {src => core/src}/plugin/manifest.rs (100%) rename {src => core/src}/plugin/mod.rs (100%) rename {src => core/src}/plugin/sandbox.rs (100%) rename {src => core/src}/resources/assets/livereload.js (100%) rename {src => core/src}/resources/assets/style.css (100%) rename {src => core/src}/resources/content/index.norg (100%) rename {src => core/src}/resources/templates/base.html (100%) rename {src => core/src}/resources/templates/categories.html (100%) rename {src => core/src}/resources/templates/category.html (100%) rename {src => core/src}/resources/templates/default.html (100%) rename {src => core/src}/resources/templates/rss.xml (100%) rename {src => core/src}/schema/mod.rs (100%) rename {src => core/src}/schema/validator.rs (100%) rename {src => core/src}/shared/mod.rs (100%) rename {src => core/src}/tera_functions.rs (100%) rename {src => core/src}/theme.rs (100%) rename {tests => core/tests}/plugins/test_abort.c (100%) rename {tests => core/tests}/plugins/test_error.c (100%) rename {tests => core/tests}/plugins/test_null.c (100%) rename {tests => core/tests}/plugins/test_ok.c (100%) rename {tests => core/tests}/plugins/test_timeout.c (100%) create mode 100644 sdk/Cargo.toml create mode 100644 sdk/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 1fbb9cb..1e207f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,17 +109,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object", ] @@ -132,28 +126,27 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "atom_syndication" -version = "0.12.7" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb" dependencies = [ "chrono", "derive_builder", "diligent-date-parser", - "never", "quick-xml", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64-simd" @@ -172,15 +165,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -213,19 +206,19 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecheck" @@ -257,15 +250,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.2.61" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -281,9 +274,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -361,7 +354,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -512,7 +505,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crossterm_winapi", "document-features", "parking_lot", @@ -568,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -592,7 +585,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -603,7 +596,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -652,7 +645,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -662,7 +655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -713,13 +706,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -760,9 +753,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -790,7 +783,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -919,7 +912,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1001,27 +994,24 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1030,7 +1020,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "libgit2-sys", "log", @@ -1049,7 +1039,7 @@ dependencies = [ "bstr", "log", "regex-automata 0.4.14", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -1058,7 +1048,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "ignore", "walkdir", ] @@ -1120,9 +1110,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1158,9 +1148,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -1328,12 +1318,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1363,9 +1347,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -1390,7 +1374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1406,11 +1390,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "inotify-sys", "libc", ] @@ -1430,7 +1414,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "chrono", "crossterm 0.25.0", "dyn-clone", @@ -1512,21 +1496,20 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -1534,11 +1517,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", ] @@ -1559,12 +1542,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -1573,9 +1550,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" -version = "0.18.3+1.9.2" +version = "0.18.5+1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" dependencies = [ "cc", "libc", @@ -1603,14 +1580,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.8.1", ] [[package]] @@ -1629,9 +1606,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -1646,7 +1623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb6314c2f0590ac93c86099b98bb7ba8abcf759bfd89604ffca906472bb54937" dependencies = [ "ahash 0.8.12", - "bitflags 2.11.1", + "bitflags 2.13.0", "const-str", "cssparser", "cssparser-color", @@ -1699,9 +1676,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "local-ip-address" -version = "0.6.12" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b0187df4e614e42405b49511b82ff7a1774fbd9a816060ee465067847cac22" +checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3" dependencies = [ "libc", "neli", @@ -1719,9 +1696,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "matchers" @@ -1740,9 +1717,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -1826,9 +1803,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -1859,7 +1836,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1868,7 +1845,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "derive_builder", "getset", @@ -1888,15 +1865,9 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.118", ] -[[package]] -name = "never" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" - [[package]] name = "newline-converter" version = "0.3.0" @@ -1959,19 +1930,28 @@ dependencies = [ "whoami", ] +[[package]] +name = "norgolith-plugin-sdk" +version = "0.1.0" +dependencies = [ + "libc", + "serde", + "serde_json", +] + [[package]] name = "notify" version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "fsevent-sys", "inotify", "kqueue", "libc", "log", - "mio 1.2.0", + "mio 1.2.1", "notify-types", "walkdir", "windows-sys 0.60.2", @@ -1996,7 +1976,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -2051,9 +2031,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "is-wsl", "libc", @@ -2068,18 +2048,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2112,7 +2092,7 @@ version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser", "log", "phf", @@ -2248,7 +2228,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2301,7 +2281,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2381,38 +2361,6 @@ dependencies = [ "termtree", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2454,9 +2402,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "encoding_rs", "memchr", @@ -2464,9 +2412,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2574,16 +2522,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -2599,14 +2547,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick 1.1.4", "memchr", "regex-automata 0.4.14", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -2626,7 +2574,7 @@ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick 1.1.4", "memchr", - "regex-syntax 0.8.10", + "regex-syntax 0.8.11", ] [[package]] @@ -2637,9 +2585,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" @@ -2681,20 +2629,19 @@ dependencies = [ [[package]] name = "rss" -version = "2.0.12" +version = "2.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf" +checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5" dependencies = [ "atom_syndication", "derive_builder", - "never", "quick-xml", ] [[package]] name = "rust-norg" version = "0.1.0" -source = "git+https://github.com/nvim-neorg/rust-norg?branch=push-vznuopvolwup#02e2ab19ad6976d5916a8bc6e9cdc38302dc0051" +source = "git+https://github.com/nvim-neorg/rust-norg?branch=push-vznuopvolwup#9024eae647244a67e41bb1e387f7f7b8760d0eff" dependencies = [ "chumsky", "itertools 0.13.0", @@ -2721,7 +2668,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -2743,27 +2690,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "seahash" version = "4.1.0" @@ -2816,14 +2748,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap", "itoa", @@ -2844,28 +2776,27 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2901,9 +2832,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -2975,15 +2906,15 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smawk" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "socket2" @@ -2997,9 +2928,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3054,9 +2985,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -3071,7 +3002,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3087,7 +3018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3159,7 +3090,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3207,16 +3138,16 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio 1.2.0", + "mio 1.2.1", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -3229,7 +3160,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3336,7 +3267,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3393,7 +3324,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -3404,9 +3335,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -3434,9 +3365,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -3450,12 +3381,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -3500,9 +3425,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "js-sys", "wasm-bindgen", @@ -3559,20 +3484,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -3583,9 +3499,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -3596,9 +3512,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3606,65 +3522,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -3733,7 +3615,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3744,7 +3626,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4011,100 +3893,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -4122,9 +3916,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4139,35 +3933,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -4180,7 +3974,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -4214,7 +4008,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2a5499b..85f60fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,75 +1,2 @@ -[package] -name = "norgolith" -version = "0.4.0" -edition = "2021" -authors = ["NTBBloodbath Result<()> { .await .map_err(|e| eyre!("Failed to write style.css: {}", e))?; - let norgolith_logo = include_str!("../../res/norgolith.svg"); + let norgolith_logo = include_str!("../../../res/norgolith.svg"); let logo_path = assets_dir.join("norgolith.svg"); debug!(logo_path = %logo_path.display(), "Writing norgolith.svg"); fs::write(&logo_path, norgolith_logo) diff --git a/src/cmd/mod.rs b/core/src/cmd/mod.rs similarity index 100% rename from src/cmd/mod.rs rename to core/src/cmd/mod.rs diff --git a/src/cmd/new.rs b/core/src/cmd/new.rs similarity index 100% rename from src/cmd/new.rs rename to core/src/cmd/new.rs diff --git a/src/cmd/plugin.rs b/core/src/cmd/plugin.rs similarity index 100% rename from src/cmd/plugin.rs rename to core/src/cmd/plugin.rs diff --git a/src/cmd/preview.rs b/core/src/cmd/preview.rs similarity index 100% rename from src/cmd/preview.rs rename to core/src/cmd/preview.rs diff --git a/src/cmd/theme.rs b/core/src/cmd/theme.rs similarity index 100% rename from src/cmd/theme.rs rename to core/src/cmd/theme.rs diff --git a/src/config/mod.rs b/core/src/config/mod.rs similarity index 100% rename from src/config/mod.rs rename to core/src/config/mod.rs diff --git a/src/converter/html.rs b/core/src/converter/html.rs similarity index 100% rename from src/converter/html.rs rename to core/src/converter/html.rs diff --git a/src/converter/meta.rs b/core/src/converter/meta.rs similarity index 100% rename from src/converter/meta.rs rename to core/src/converter/meta.rs diff --git a/src/converter/mod.rs b/core/src/converter/mod.rs similarity index 100% rename from src/converter/mod.rs rename to core/src/converter/mod.rs diff --git a/src/fs.rs b/core/src/fs.rs similarity index 100% rename from src/fs.rs rename to core/src/fs.rs diff --git a/src/main.rs b/core/src/main.rs similarity index 100% rename from src/main.rs rename to core/src/main.rs diff --git a/src/net.rs b/core/src/net.rs similarity index 100% rename from src/net.rs rename to core/src/net.rs diff --git a/src/plugin/ffi.rs b/core/src/plugin/ffi.rs similarity index 100% rename from src/plugin/ffi.rs rename to core/src/plugin/ffi.rs diff --git a/src/plugin/manifest.rs b/core/src/plugin/manifest.rs similarity index 100% rename from src/plugin/manifest.rs rename to core/src/plugin/manifest.rs diff --git a/src/plugin/mod.rs b/core/src/plugin/mod.rs similarity index 100% rename from src/plugin/mod.rs rename to core/src/plugin/mod.rs diff --git a/src/plugin/sandbox.rs b/core/src/plugin/sandbox.rs similarity index 100% rename from src/plugin/sandbox.rs rename to core/src/plugin/sandbox.rs diff --git a/src/resources/assets/livereload.js b/core/src/resources/assets/livereload.js similarity index 100% rename from src/resources/assets/livereload.js rename to core/src/resources/assets/livereload.js diff --git a/src/resources/assets/style.css b/core/src/resources/assets/style.css similarity index 100% rename from src/resources/assets/style.css rename to core/src/resources/assets/style.css diff --git a/src/resources/content/index.norg b/core/src/resources/content/index.norg similarity index 100% rename from src/resources/content/index.norg rename to core/src/resources/content/index.norg diff --git a/src/resources/templates/base.html b/core/src/resources/templates/base.html similarity index 100% rename from src/resources/templates/base.html rename to core/src/resources/templates/base.html diff --git a/src/resources/templates/categories.html b/core/src/resources/templates/categories.html similarity index 100% rename from src/resources/templates/categories.html rename to core/src/resources/templates/categories.html diff --git a/src/resources/templates/category.html b/core/src/resources/templates/category.html similarity index 100% rename from src/resources/templates/category.html rename to core/src/resources/templates/category.html diff --git a/src/resources/templates/default.html b/core/src/resources/templates/default.html similarity index 100% rename from src/resources/templates/default.html rename to core/src/resources/templates/default.html diff --git a/src/resources/templates/rss.xml b/core/src/resources/templates/rss.xml similarity index 100% rename from src/resources/templates/rss.xml rename to core/src/resources/templates/rss.xml diff --git a/src/schema/mod.rs b/core/src/schema/mod.rs similarity index 100% rename from src/schema/mod.rs rename to core/src/schema/mod.rs diff --git a/src/schema/validator.rs b/core/src/schema/validator.rs similarity index 100% rename from src/schema/validator.rs rename to core/src/schema/validator.rs diff --git a/src/shared/mod.rs b/core/src/shared/mod.rs similarity index 100% rename from src/shared/mod.rs rename to core/src/shared/mod.rs diff --git a/src/tera_functions.rs b/core/src/tera_functions.rs similarity index 100% rename from src/tera_functions.rs rename to core/src/tera_functions.rs diff --git a/src/theme.rs b/core/src/theme.rs similarity index 100% rename from src/theme.rs rename to core/src/theme.rs diff --git a/tests/plugins/test_abort.c b/core/tests/plugins/test_abort.c similarity index 100% rename from tests/plugins/test_abort.c rename to core/tests/plugins/test_abort.c diff --git a/tests/plugins/test_error.c b/core/tests/plugins/test_error.c similarity index 100% rename from tests/plugins/test_error.c rename to core/tests/plugins/test_error.c diff --git a/tests/plugins/test_null.c b/core/tests/plugins/test_null.c similarity index 100% rename from tests/plugins/test_null.c rename to core/tests/plugins/test_null.c diff --git a/tests/plugins/test_ok.c b/core/tests/plugins/test_ok.c similarity index 100% rename from tests/plugins/test_ok.c rename to core/tests/plugins/test_ok.c diff --git a/tests/plugins/test_timeout.c b/core/tests/plugins/test_timeout.c similarity index 100% rename from tests/plugins/test_timeout.c rename to core/tests/plugins/test_timeout.c diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml new file mode 100644 index 0000000..e427739 --- /dev/null +++ b/sdk/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "norgolith-plugin-sdk" +version = "0.1.0" +edition = "2021" +authors = ["NTBBloodbath *mut c_char; + +/// Function pointer type for freeing plugin-allocated strings +pub type FreeStringFn = extern "C" fn(*mut c_char); + +/// Context for the pre_build hook +#[derive(serde::Deserialize)] +pub struct PreBuildContext { + pub site_config: serde_json::Value, + pub pages_dir: String, + pub output_dir: String, +} + +/// Context for post_convert and post_render hooks +#[derive(serde::Deserialize)] +pub struct TransformContext { + pub html: String, + pub metadata: serde_json::Value, + pub rel_path: String, +} + +/// Context for the post_build hook +#[derive(serde::Deserialize)] +pub struct PostBuildContext { + pub site_config: serde_json::Value, + pub pages_dir: String, + pub output_dir: String, +} + +/// Builder for registering plugin hooks +pub struct PluginBuilder { + name: &'static str, + version: &'static str, + pre_build: Option, + post_convert: Option, + post_render: Option, + post_build: Option, +} + +impl PluginBuilder { + /// Create a new plugin builder + pub fn new(name: &'static str, version: &'static str) -> Self { + Self { + name, + version, + pre_build: None, + post_convert: None, + post_render: None, + post_build: None, + } + } + + /// Register a pre_build hook + pub fn on_pre_build(mut self, _handler: F) -> Self + where + F: Fn(PreBuildContext) -> Result, String> + 'static, + { + // Bridge function will be generated by the macro + self + } + + /// Register a post_convert hook + pub fn on_post_convert(mut self, _handler: F) -> Self + where + F: Fn(TransformContext) -> Result, String> + 'static, + { + // Bridge function will be generated by the macro + self + } + + /// Register a post_render hook + pub fn on_post_render(mut self, _handler: F) -> Self + where + F: Fn(TransformContext) -> Result, String> + 'static, + { + // Bridge function will be generated by the macro + self + } + + /// Register a post_build hook + pub fn on_post_build(mut self, _handler: F) -> Self + where + F: Fn(PostBuildContext) -> Result, String> + 'static, + { + // Bridge function will be generated by the macro + self + } + + /// Finalize registration (placeholder - real implementation will be in macro) + pub fn register(self) { + // This is a placeholder - the real implementation will use the macro + } +} + +/// Helper to convert Rust string to C string for plugin return values +pub fn to_c_string(s: &str) -> *mut c_char { + CString::new(s) + .unwrap_or_default() + .into_raw() +} + +/// Helper to convert C string from plugin input to Rust string +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub fn from_c_string(ptr: *const c_char) -> Option { + if ptr.is_null() { + return None; + } + unsafe { CStr::from_ptr(ptr) } + .to_str() + .ok() + .map(|s| s.to_string()) +} + +/// Register a plugin with the given name and version +/// +/// This macro generates the required `norgolith_plugin_init` function and +/// bridge functions for each registered hook. +/// +/// # Example +/// +/// ```rust +/// use norgolith_plugin_sdk::*; +/// +/// register_plugin!("my-plugin", "0.1.0") +/// .on_post_render(|ctx| { +/// // Transform the HTML +/// Ok(Some(ctx.html)) +/// }) +/// .register(); +/// ``` +#[macro_export] +macro_rules! register_plugin { + ($name:expr, $version:expr) => { + $crate::PluginBuilder::new($name, $version) + }; +} From 57c000e496a273abffde8048250ac693980bb245 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 17:28:46 -0400 Subject: [PATCH 08/12] feat(sdk): implement bridge functions and working register_plugin! macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - __bridge_json: universal JSON I/O bridge (C string → handler → JSON/NULL/error) - __set_hook: maps hook name to bitmask + array index - register_plugin! generates init fn + bridge fns per hook - integration test: Rust cdylib plugin built + loaded by PluginManager --- .gitignore | 1 + Cargo.toml | 1 + core/build.rs | 41 +++ core/src/plugin/mod.rs | 54 ++++ core/tests/plugins/test_sdk_plugin/Cargo.lock | 122 ++++++++ core/tests/plugins/test_sdk_plugin/Cargo.toml | 13 + core/tests/plugins/test_sdk_plugin/src/lib.rs | 11 + sdk/src/lib.rs | 273 ++++++++++++------ 8 files changed, 424 insertions(+), 92 deletions(-) create mode 100644 core/tests/plugins/test_sdk_plugin/Cargo.lock create mode 100644 core/tests/plugins/test_sdk_plugin/Cargo.toml create mode 100644 core/tests/plugins/test_sdk_plugin/src/lib.rs diff --git a/.gitignore b/.gitignore index 1b69360..ced31b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Added by cargo /target +target/ # Testing junk /my-site diff --git a/Cargo.toml b/Cargo.toml index 85f60fd..98279b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,3 @@ [workspace] members = ["core", "sdk"] +resolver = "2" diff --git a/core/build.rs b/core/build.rs index 5e758fa..6036c0f 100644 --- a/core/build.rs +++ b/core/build.rs @@ -13,6 +13,7 @@ fn main() { // is small enough not to care about it. I cannot be bothered to add '--features test-plugins' // to the 'cargo nextest run' command arguments every time and complaining when I forget it compile_test_plugins(); + compile_rust_test_plugins(); } fn get_version() -> String { @@ -94,3 +95,43 @@ fn compile_test_plugins() { } } } + +fn compile_rust_test_plugins() { + let plugins_dir = PathBuf::from("tests/plugins"); + if !plugins_dir.is_dir() { + return; + } + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let target_dir = out_dir.parent().unwrap().parent().unwrap(); + + let entries = match std::fs::read_dir(&plugins_dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_dir() || !path.join("Cargo.toml").exists() { + continue; + } + + let name = path.file_name().and_then(|s| s.to_str()).unwrap(); + + let mut cmd = Command::new("cargo"); + cmd.arg("build") + .arg("--release") + .arg("--manifest-path") + .arg(path.join("Cargo.toml")) + .arg("--target-dir") + .arg(target_dir); + + let status = cmd.status(); + if status.as_ref().map(|s| !s.success()).unwrap_or(true) { + println!( + "cargo:warning=failed to compile Rust test plugin {}: {:?}", + name, status + ); + } + } +} diff --git a/core/src/plugin/mod.rs b/core/src/plugin/mod.rs index b2739ee..ffd476d 100644 --- a/core/src/plugin/mod.rs +++ b/core/src/plugin/mod.rs @@ -445,4 +445,58 @@ timeout_ms = 5000 let result = p.call_hook(p.hooks.post_render.unwrap(), input); assert_eq!(result, None); } + + #[test] + fn test_sdk_plugin_load() { + let tmp = tempfile::tempdir().unwrap(); + let plugin_dir = tmp.path().join("plugins").join("test-sdk-plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + + // Write manifest for the SDK plugin + let manifest = r#"[plugin] +name = "test-sdk-plugin" +version = "0.1.0" +norgolith = ">=0.4.0" +abi = 1 + +[hooks] +pre_build = false +post_convert = false +post_render = true +post_build = false + +[capabilities] +filesystem = "none" +network = false + +timeout_ms = 5000 +"#; + std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap(); + + // Find the compiled SDK plugin library + let out_dir = PathBuf::from(env!("OUT_DIR")); + let target_dir = out_dir.parent().unwrap().parent().unwrap(); + let release_dir = target_dir.parent().unwrap().join("release"); + + let lib_name = library_filename("test-sdk-plugin"); + let src = release_dir.join(&lib_name); + if !src.is_file() { + eprintln!("SDK test plugin not compiled, skipping"); + return; + } + std::fs::copy(&src, plugin_dir.join(&lib_name)).unwrap(); + + let mgr = PluginManager::load(tmp.path()); + assert_eq!(mgr.len(), 1, "should load exactly one plugin"); + + let p = mgr.plugins().next().unwrap(); + assert_eq!(p.name, "test-sdk-plugin"); + assert_eq!(p.version, "0.1.0"); + assert!(p.hooks.post_render.is_some(), "post_render hook should be set"); + + let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; + let result = p.call_hook(p.hooks.post_render.unwrap(), input); + assert!(result.is_some(), "plugin should return modified HTML"); + assert!(result.unwrap().contains(""), "should contain plugin marker"); + } } diff --git a/core/tests/plugins/test_sdk_plugin/Cargo.lock b/core/tests/plugins/test_sdk_plugin/Cargo.lock new file mode 100644 index 0000000..f836c0d --- /dev/null +++ b/core/tests/plugins/test_sdk_plugin/Cargo.lock @@ -0,0 +1,122 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "norgolith-plugin-sdk" +version = "0.1.0" +dependencies = [ + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "test_sdk_plugin" +version = "0.1.0" +dependencies = [ + "norgolith-plugin-sdk", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/core/tests/plugins/test_sdk_plugin/Cargo.toml b/core/tests/plugins/test_sdk_plugin/Cargo.toml new file mode 100644 index 0000000..edea83c --- /dev/null +++ b/core/tests/plugins/test_sdk_plugin/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "test_sdk_plugin" +version = "0.1.0" +edition = "2021" + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +norgolith-plugin-sdk = { path = "../../../../sdk" } +serde_json = "1.0" diff --git a/core/tests/plugins/test_sdk_plugin/src/lib.rs b/core/tests/plugins/test_sdk_plugin/src/lib.rs new file mode 100644 index 0000000..60b6541 --- /dev/null +++ b/core/tests/plugins/test_sdk_plugin/src/lib.rs @@ -0,0 +1,11 @@ +use norgolith_plugin_sdk::*; + +fn post_render_handler(json: serde_json::Value) -> Result, String> { + let ctx: TransformContext = serde_json::from_value(json).map_err(|e| e.to_string())?; + // Echo back the HTML with a marker + Ok(Some(format!("{}", ctx.html))) +} + +register_plugin!("test-sdk-plugin", "0.1.0", + hooks: [post_render: post_render_handler] +); diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index 5721eb7..3db857a 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(dead_code, unused_mut)] - use std::ffi::{CStr, CString}; use std::os::raw::c_char; @@ -26,9 +24,6 @@ pub struct PluginInfo { /// Function pointer type for plugin hooks pub type PluginFn = extern "C" fn(*const c_char) -> *mut c_char; -/// Function pointer type for freeing plugin-allocated strings -pub type FreeStringFn = extern "C" fn(*mut c_char); - /// Context for the pre_build hook #[derive(serde::Deserialize)] pub struct PreBuildContext { @@ -53,110 +48,204 @@ pub struct PostBuildContext { pub output_dir: String, } -/// Builder for registering plugin hooks -pub struct PluginBuilder { - name: &'static str, - version: &'static str, - pre_build: Option, - post_convert: Option, - post_render: Option, - post_build: Option, -} - -impl PluginBuilder { - /// Create a new plugin builder - pub fn new(name: &'static str, version: &'static str) -> Self { - Self { - name, - version, - pre_build: None, - post_convert: None, - post_render: None, - post_build: None, - } - } - - /// Register a pre_build hook - pub fn on_pre_build(mut self, _handler: F) -> Self - where - F: Fn(PreBuildContext) -> Result, String> + 'static, - { - // Bridge function will be generated by the macro - self - } - - /// Register a post_convert hook - pub fn on_post_convert(mut self, _handler: F) -> Self - where - F: Fn(TransformContext) -> Result, String> + 'static, - { - // Bridge function will be generated by the macro - self - } - - /// Register a post_render hook - pub fn on_post_render(mut self, _handler: F) -> Self - where - F: Fn(TransformContext) -> Result, String> + 'static, - { - // Bridge function will be generated by the macro - self - } +/// Universal bridge function: reads C input → calls handler → returns JSON/NULL/error +/// +/// The handler receives a `serde_json::Value` and must return: +/// - `Ok(Some(html))` — modified content (returned as `{"html":"..."}`) +/// - `Ok(None)` — no change (returned as NULL) +/// - `Err(msg)` — error (returned as `{"error":"..."}`) +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub fn __bridge_json(input: *const c_char, handler: F) -> *mut c_char +where + F: FnOnce(serde_json::Value) -> Result, String>, +{ + let input_str = unsafe { CStr::from_ptr(input) } + .to_str() + .unwrap_or("{}"); - /// Register a post_build hook - pub fn on_post_build(mut self, _handler: F) -> Self - where - F: Fn(PostBuildContext) -> Result, String> + 'static, - { - // Bridge function will be generated by the macro - self - } + let value: serde_json::Value = + serde_json::from_str(input_str).unwrap_or(serde_json::Value::Null); - /// Finalize registration (placeholder - real implementation will be in macro) - pub fn register(self) { - // This is a placeholder - the real implementation will use the macro + match handler(value) { + Ok(Some(html)) => { + let output = serde_json::json!({"html": html}).to_string(); + CString::new(output).unwrap().into_raw() + } + Ok(None) => std::ptr::null_mut(), + Err(e) => { + let output = serde_json::json!({"error": e}).to_string(); + CString::new(output).unwrap().into_raw() + } } } -/// Helper to convert Rust string to C string for plugin return values -pub fn to_c_string(s: &str) -> *mut c_char { - CString::new(s) - .unwrap_or_default() - .into_raw() -} - -/// Helper to convert C string from plugin input to Rust string -#[allow(clippy::not_unsafe_ptr_arg_deref)] -pub fn from_c_string(ptr: *const c_char) -> Option { - if ptr.is_null() { - return None; +/// Set a hook function pointer and mask bit by name +/// +/// Hook names map to array indices: pre_build=0, post_convert=1, post_render=2, post_build=3 +pub fn __set_hook( + name: &str, + mask: &mut u32, + hooks: &mut [Option; 4], + func: PluginFn, +) { + match name { + "pre_build" => { + *mask |= HOOK_PRE_BUILD; + hooks[0] = Some(func); + } + "post_convert" => { + *mask |= HOOK_POST_CONVERT; + hooks[1] = Some(func); + } + "post_render" => { + *mask |= HOOK_POST_RENDER; + hooks[2] = Some(func); + } + "post_build" => { + *mask |= HOOK_POST_BUILD; + hooks[3] = Some(func); + } + _ => {} } - unsafe { CStr::from_ptr(ptr) } - .to_str() - .ok() - .map(|s| s.to_string()) } -/// Register a plugin with the given name and version +/// Register a plugin with the given name and version. /// -/// This macro generates the required `norgolith_plugin_init` function and -/// bridge functions for each registered hook. +/// Generates the `norgolith_plugin_init` function and bridge functions for each hook. /// /// # Example /// /// ```rust /// use norgolith_plugin_sdk::*; /// -/// register_plugin!("my-plugin", "0.1.0") -/// .on_post_render(|ctx| { -/// // Transform the HTML -/// Ok(Some(ctx.html)) -/// }) -/// .register(); +/// fn highlight(json: serde_json::Value) -> Result, String> { +/// let ctx: TransformContext = serde_json::from_value(json).map_err(|e| e.to_string())?; +/// Ok(Some(ctx.html)) +/// } +/// +/// register_plugin!("my-plugin", "0.1.0", +/// hooks: [post_render: highlight] +/// ); /// ``` #[macro_export] macro_rules! register_plugin { - ($name:expr, $version:expr) => { - $crate::PluginBuilder::new($name, $version) + ($name:expr, $version:expr, hooks: [$($hook:ident : $handler:ident),* $(,)?]) => { + // Generate one bridge function per hook + $( + #[no_mangle] + pub extern "C" fn $hook(input: *const ::std::os::raw::c_char) -> *mut ::std::os::raw::c_char { + $crate::__bridge_json(input, $handler) + } + )* + + // Generate the init function + #[no_mangle] + pub extern "C" fn norgolith_plugin_init( + info: &mut $crate::PluginInfo, + mask: &mut u32, + hooks: &mut [Option<$crate::PluginFn>; 4], + ) { + *info = $crate::PluginInfo { + abi_version: $crate::CORE_ABI_VERSION, + name: concat!($name, "\0").as_ptr() as *const ::std::os::raw::c_char, + version: concat!($version, "\0").as_ptr() as *const ::std::os::raw::c_char, + }; + *mask = 0; + *hooks = [None, None, None, None]; + $( + $crate::__set_hook(stringify!($hook), mask, hooks, $hook as $crate::PluginFn); + )* + } }; } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + + #[test] + fn test_bridge_json_success() { + let input = CString::new(r#"{"html":"hello","metadata":{},"rel_path":"test.norg"}"#).unwrap(); + + fn handler(json: serde_json::Value) -> Result, String> { + let html = json.get("html").and_then(|v| v.as_str()).unwrap(); + Ok(Some(format!("{} world", html))) + } + + let result = __bridge_json(input.as_ptr(), handler); + assert!(!result.is_null()); + + let output = unsafe { CStr::from_ptr(result) }.to_str().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(output).unwrap(); + assert_eq!(parsed.get("html").and_then(|v| v.as_str()).unwrap(), "hello world"); + } + + #[test] + fn test_bridge_json_no_change() { + let input = CString::new(r#"{"html":"keep","metadata":{},"rel_path":"test.norg"}"#).unwrap(); + + fn handler(_json: serde_json::Value) -> Result, String> { + Ok(None) + } + + let result = __bridge_json(input.as_ptr(), handler); + assert!(result.is_null()); + } + + #[test] + fn test_bridge_json_error() { + let input = CString::new(r#"{"html":"fail","metadata":{},"rel_path":"test.norg"}"#).unwrap(); + + fn handler(_json: serde_json::Value) -> Result, String> { + Err("something went wrong".to_string()) + } + + let result = __bridge_json(input.as_ptr(), handler); + assert!(!result.is_null()); + + let output = unsafe { CStr::from_ptr(result) }.to_str().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(output).unwrap(); + assert_eq!(parsed.get("error").and_then(|v| v.as_str()).unwrap(), "something went wrong"); + } + + #[test] + fn test_set_hook_all_names() { + let mut mask = 0u32; + let mut hooks: [Option; 4] = [None, None, None, None]; + + extern "C" fn dummy(_input: *const c_char) -> *mut c_char { + std::ptr::null_mut() + } + + __set_hook("pre_build", &mut mask, &mut hooks, dummy); + assert_eq!(mask, HOOK_PRE_BUILD); + assert!(hooks[0].is_some()); + + __set_hook("post_convert", &mut mask, &mut hooks, dummy); + assert_eq!(mask, HOOK_PRE_BUILD | HOOK_POST_CONVERT); + assert!(hooks[1].is_some()); + + __set_hook("post_render", &mut mask, &mut hooks, dummy); + assert_eq!(mask, HOOK_PRE_BUILD | HOOK_POST_CONVERT | HOOK_POST_RENDER); + assert!(hooks[2].is_some()); + + __set_hook("post_build", &mut mask, &mut hooks, dummy); + assert_eq!(mask, HOOK_PRE_BUILD | HOOK_POST_CONVERT | HOOK_POST_RENDER | HOOK_POST_BUILD); + assert!(hooks[3].is_some()); + } + + #[test] + fn test_set_hook_unknown_name() { + let mut mask = 0u32; + let mut hooks: [Option; 4] = [None, None, None, None]; + + extern "C" fn dummy(_input: *const c_char) -> *mut c_char { + std::ptr::null_mut() + } + + __set_hook("unknown_hook", &mut mask, &mut hooks, dummy); + assert_eq!(mask, 0); + assert!(hooks.iter().all(|h| h.is_none())); + } +} From 14d8480df495029f1378bdb2c52c81c6dff36d81 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 18:11:43 -0400 Subject: [PATCH 09/12] fix(plugin): remove double JSON extraction in hook handlers call_hook() already extracts html from JSON via parse_hook_response(). The build and dev commands were parsing the result a second time, silently dropping all plugin output Also fix build output: add blank line before Plugins section, remove blank line between Plugins and Contenr --- core/src/cmd/build.rs | 17 ++++------------- core/src/cmd/dev.rs | 14 +++----------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/core/src/cmd/build.rs b/core/src/cmd/build.rs index 84fec90..811956d 100644 --- a/core/src/cmd/build.rs +++ b/core/src/cmd/build.rs @@ -356,12 +356,8 @@ fn build_content_entry( for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_convert { if let Some(new_html) = p.call_hook(f, &input) { - if let Ok(val) = serde_json::from_str::(&new_html) { - if let Some(s) = val.get("html").and_then(|v| v.as_str()) { - if let toml::Value::Table(ref mut table) = metadata { - table.insert("raw".to_string(), toml::Value::String(s.to_string())); - } - } + if let toml::Value::Table(ref mut table) = metadata { + table.insert("raw".to_string(), toml::Value::String(new_html)); } } } @@ -386,11 +382,7 @@ fn build_content_entry( for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_render { if let Some(new_html) = p.call_hook(f, &input) { - if let Ok(val) = serde_json::from_str::(&new_html) { - if let Some(s) = val.get("html").and_then(|v| v.as_str()) { - rendered = s.to_string(); - } - } + rendered = new_html; } } } @@ -917,6 +909,7 @@ pub fn build(minify: bool) -> Result<()> { let _ = plugin::sandbox::apply_landlock(&root_dir); timings.plugins_ms = t.elapsed().as_millis(); + println!(); if !plugin_mgr.is_empty() { println!( " {} {} {} plugins", @@ -982,8 +975,6 @@ pub fn build(minify: bool) -> Result<()> { let mut cache = BuildCache::open(&root_dir)?; timings.cache_open_ms = t.elapsed().as_millis(); - println!(); - // Build content let t = Instant::now(); let (page_count, content_timings) = build_contents(&tera, &paths, &posts, &site_config, &shared_context, &mut cache, minify, &plugin_mgr)?; diff --git a/core/src/cmd/dev.rs b/core/src/cmd/dev.rs index 3f264c5..8c4f727 100644 --- a/core/src/cmd/dev.rs +++ b/core/src/cmd/dev.rs @@ -1140,12 +1140,8 @@ fn render_all_pages( for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_convert { if let Some(new_html) = p.call_hook(f, &input) { - if let Ok(val) = serde_json::from_str::(&new_html) { - if let Some(s) = val.get("html").and_then(|v| v.as_str()) { - if let toml::Value::Table(ref mut table) = metadata { - table.insert("raw".to_string(), toml::Value::String(s.to_string())); - } - } + if let toml::Value::Table(ref mut table) = metadata { + table.insert("raw".to_string(), toml::Value::String(new_html)); } } } @@ -1166,11 +1162,7 @@ fn render_all_pages( for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_render { if let Some(new_html) = p.call_hook(f, &input) { - if let Ok(val) = serde_json::from_str::(&new_html) { - if let Some(s) = val.get("html").and_then(|v| v.as_str()) { - body = s.to_string(); - } - } + body = new_html; } } } From 7d8822a233e839a04ec35a4896157bd6776b924a Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 20:42:00 -0400 Subject: [PATCH 10/12] fix(plugin): harden plugin system - Validate plugin names to block path traversal - Add priority field for deterministic execution ordering - Propagate hook errors visibly to user - Track and print per-plugin hook timing - Document allocator assumption: libc::free assumes system allocator - Document Landlock timing gap: .so constructors run before sandbox --- core/src/cmd/build.rs | 68 +++++++++++++++++++++++++++++++++---- core/src/cmd/dev.rs | 43 +++++++++++++++++++---- core/src/cmd/plugin.rs | 25 +++++++++++--- core/src/plugin/ffi.rs | 6 ++++ core/src/plugin/manifest.rs | 7 ++++ core/src/plugin/mod.rs | 64 ++++++++++++++++++++++++++-------- core/src/plugin/sandbox.rs | 7 ++++ 7 files changed, 189 insertions(+), 31 deletions(-) diff --git a/core/src/cmd/build.rs b/core/src/cmd/build.rs index 811956d..1b399ae 100644 --- a/core/src/cmd/build.rs +++ b/core/src/cmd/build.rs @@ -355,9 +355,21 @@ fn build_content_entry( .to_string(); for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_convert { - if let Some(new_html) = p.call_hook(f, &input) { - if let toml::Value::Table(ref mut table) = metadata { - table.insert("raw".to_string(), toml::Value::String(new_html)); + match plugin_mgr.call_hook(p, f, &input) { + Ok(Some(new_html)) => { + if let toml::Value::Table(ref mut table) = metadata { + table.insert("raw".to_string(), toml::Value::String(new_html)); + } + } + Ok(None) => {} + Err(e) => { + error!( + "{} plugin '{}' on {}: {}", + "Plugin error:".red().bold(), + p.name.bold(), + rel_path.display(), + e + ); } } } @@ -381,8 +393,20 @@ fn build_content_entry( .to_string(); for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_render { - if let Some(new_html) = p.call_hook(f, &input) { - rendered = new_html; + match plugin_mgr.call_hook(p, f, &input) { + Ok(Some(new_html)) => { + rendered = new_html; + } + Ok(None) => {} + Err(e) => { + error!( + "{} plugin '{}' on {}: {}", + "Plugin error:".red().bold(), + p.name.bold(), + rel_path.display(), + e + ); + } } } } @@ -925,7 +949,14 @@ pub fn build(minify: bool) -> Result<()> { .unwrap_or_default(); for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.pre_build { - p.call_hook(f, &config_json); + if let Err(e) = plugin_mgr.call_hook(p, f, &config_json) { + error!( + "{} plugin '{}': {}", + "Plugin error:".red().bold(), + p.name.bold(), + e + ); + } } } } @@ -1041,7 +1072,14 @@ pub fn build(minify: bool) -> Result<()> { .unwrap_or_default(); for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_build { - p.call_hook(f, &config_json); + if let Err(e) = plugin_mgr.call_hook(p, f, &config_json) { + error!( + "{} plugin '{}': {}", + "Plugin error:".red().bold(), + p.name.bold(), + e + ); + } } } } @@ -1054,6 +1092,22 @@ pub fn build(minify: bool) -> Result<()> { shared::get_elapsed_time(build_start) ); + // Print per-plugin timing if any hooks were called + let plugin_timings = plugin_mgr.hook_timings(); + if !plugin_timings.is_empty() { + println!(); + println!("{}", " Plugin hook timings:".dimmed()); + let mut sorted: Vec<_> = plugin_timings.iter().collect(); + sorted.sort_by(|a, b| b.1.cmp(a.1)); + for (name, duration) in sorted { + println!( + " {:<20} {:>6}ms", + name.dimmed(), + duration.as_millis() + ); + } + } + // Save cache let t = Instant::now(); if let Err(e) = cache.save() { diff --git a/core/src/cmd/dev.rs b/core/src/cmd/dev.rs index 8c4f727..131dc7d 100644 --- a/core/src/cmd/dev.rs +++ b/core/src/cmd/dev.rs @@ -1139,9 +1139,21 @@ fn render_all_pages( .to_string(); for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_convert { - if let Some(new_html) = p.call_hook(f, &input) { - if let toml::Value::Table(ref mut table) = metadata { - table.insert("raw".to_string(), toml::Value::String(new_html)); + match plugin_mgr.call_hook(p, f, &input) { + Ok(Some(new_html)) => { + if let toml::Value::Table(ref mut table) = metadata { + table.insert("raw".to_string(), toml::Value::String(new_html)); + } + } + Ok(None) => {} + Err(e) => { + error!( + "{} plugin '{}' on {}: {}", + "Plugin error:".red().bold(), + p.name.bold(), + rel_path.display(), + e + ); } } } @@ -1161,8 +1173,20 @@ fn render_all_pages( .to_string(); for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.post_render { - if let Some(new_html) = p.call_hook(f, &input) { - body = new_html; + match plugin_mgr.call_hook(p, f, &input) { + Ok(Some(new_html)) => { + body = new_html; + } + Ok(None) => {} + Err(e) => { + error!( + "{} plugin '{}' on {}: {}", + "Plugin error:".red().bold(), + p.name.bold(), + rel_path.display(), + e + ); + } } } } @@ -1315,7 +1339,14 @@ async fn setup_server_state( .to_string(); for p in plugin_mgr.plugins() { if let Some(f) = p.hooks.pre_build { - p.call_hook(f, &input); + if let Err(e) = plugin_mgr.call_hook(p, f, &input) { + error!( + "{} plugin '{}': {}", + "Plugin error:".red().bold(), + p.name.bold(), + e + ); + } } } } diff --git a/core/src/cmd/plugin.rs b/core/src/cmd/plugin.rs index 3d955d1..d811689 100644 --- a/core/src/cmd/plugin.rs +++ b/core/src/cmd/plugin.rs @@ -51,13 +51,14 @@ fn list_plugins() -> Result<()> { } println!( - "{:<20} {:<10} {:<30} {}", + "{:<20} {:<10} {:<8} {:<30} {}", "NAME".bold(), "VERSION".bold(), + "PRI".bold(), "HOOKS".bold(), "STATUS".bold() ); - println!("{}", "-".repeat(75)); + println!("{}", "-".repeat(85)); for p in mgr.plugins() { let hooks = { @@ -83,8 +84,8 @@ fn list_plugins() -> Result<()> { let status = "ok".green(); println!( - "{:<20} {:<10} {:<30} {}", - p.name, p.version, hooks, status + "{:<20} {:<10} {:<8} {:<30} {}", + p.name, p.version, p.manifest.priority, hooks, status ); } @@ -93,6 +94,8 @@ fn list_plugins() -> Result<()> { } fn new_plugin(name: &str) -> Result<()> { + validate_plugin_name(name)?; + let cwd = std::env::current_dir()?; let plugins_dir = cwd.join("plugins").join(name); @@ -126,6 +129,7 @@ filesystem = "none" network = false timeout_ms = 10000 +priority = 100 "# ); std::fs::write(plugins_dir.join("plugin.toml"), manifest)?; @@ -187,6 +191,7 @@ fn install_plugin(source_dir: &Path) -> Result<()> { } let manifest = PluginManifest::load(&manifest_path)?; + validate_plugin_name(&manifest.plugin.name)?; manifest.validate_abi()?; manifest.validate_semver()?; @@ -256,7 +261,19 @@ fn install_plugin(source_dir: &Path) -> Result<()> { Ok(()) } +fn validate_plugin_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("Plugin name cannot be empty"); + } + if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains(':') { + bail!("Invalid plugin name: '{}' (no path separators or '..' allowed)", name); + } + Ok(()) +} + fn uninstall_plugin(name: &str) -> Result<()> { + validate_plugin_name(name)?; + let cwd = std::env::current_dir()?; let plugin_dir = cwd.join("plugins").join(name); diff --git a/core/src/plugin/ffi.rs b/core/src/plugin/ffi.rs index ea60906..c60d38d 100644 --- a/core/src/plugin/ffi.rs +++ b/core/src/plugin/ffi.rs @@ -34,6 +34,12 @@ pub type FreeStringFn = extern "C" fn(*mut c_char); /// Returns `Ok(None)` if the plugin returned NULL (no change) /// Returns `Ok(Some(json))` if the plugin returned modified content /// Returns `Err(msg)` on panic, timeout, or invalid output +/// +/// # Safety Note +/// The returned pointer is freed with `libc::free`. This assumes the plugin's global allocator is +/// compatible with libc malloc (true for the default system allocator). Plugins compiled with +/// jemalloc or mimalloc will cause UB. This is an acceptable trade-off for MVP; a future version +/// can add a `plugin_free` callback to let each plugin provide its own deallocator pub fn call_hook_safe( f: PluginFn, input: &str, diff --git a/core/src/plugin/manifest.rs b/core/src/plugin/manifest.rs index 72b968e..ea05cec 100644 --- a/core/src/plugin/manifest.rs +++ b/core/src/plugin/manifest.rs @@ -18,6 +18,13 @@ pub struct PluginManifest { pub capabilities: Capabilities, #[serde(default = "default_timeout_ms")] pub timeout_ms: u64, + /// Execution priority (lower runs first, default 100) + #[serde(default = "default_priority")] + pub priority: u32, +} + +fn default_priority() -> u32 { + 100 } #[derive(Debug, Clone, Deserialize)] diff --git a/core/src/plugin/mod.rs b/core/src/plugin/mod.rs index ffd476d..65d54bf 100644 --- a/core/src/plugin/mod.rs +++ b/core/src/plugin/mod.rs @@ -4,8 +4,12 @@ pub mod ffi; pub mod manifest; pub mod sandbox; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use eyre::Result; pub use ffi::{FreeStringFn, PluginFn, PluginInfo}; pub use manifest::{ @@ -37,21 +41,25 @@ pub struct PluginInstance { impl PluginInstance { /// Call a hook on this plugin with safety wrappers (catch_unwind + timeout) - pub fn call_hook(&self, f: PluginFn, input: &str) -> Option { + /// + /// Returns `Ok(None)` if plugin returned NULL (no change) + /// Returns `Ok(Some(html))` if plugin returned modified content + /// Returns `Err` on panic, timeout, invalid response, or plugin error + pub fn call_hook(&self, f: PluginFn, input: &str) -> Result> { let timeout = Duration::from_millis(self.manifest.timeout_ms); match ffi::call_hook_safe(f, input, timeout) { Ok(Some(json)) => match ffi::parse_hook_response(&json) { - Ok(Some(html)) => Some(html), - Ok(None) => None, + Ok(Some(html)) => Ok(Some(html)), + Ok(None) => Ok(None), Err(e) => { warn!("Plugin '{}' returned invalid response: {}", self.name, e); - None + Err(e) } }, - Ok(None) => None, + Ok(None) => Ok(None), Err(e) => { warn!("Plugin '{}' hook failed: {}", self.name, e); - None + Err(e) } } } @@ -60,6 +68,8 @@ impl PluginInstance { /// Manages loaded plugins and dispatches hook calls pub struct PluginManager { plugins: Vec, + /// Per-plugin hook call timing: plugin_name -> total Duration + hook_timings: Arc>>, } impl PluginManager { @@ -67,6 +77,7 @@ impl PluginManager { pub fn new() -> Self { Self { plugins: Vec::new(), + hook_timings: Arc::new(Mutex::new(HashMap::new())), } } @@ -112,6 +123,8 @@ impl PluginManager { } } + manager.plugins.sort_by_key(|p| p.manifest.priority); + manager } @@ -136,6 +149,29 @@ impl PluginManager { .iter() .any(|p| p.manifest.hooks.to_mask() & hook_bit != 0) } + + /// Call a hook on a plugin with timing recorded + pub fn call_hook(&self, plugin: &PluginInstance, f: PluginFn, input: &str) -> Result> { + let start = Instant::now(); + let result = plugin.call_hook(f, input); + self.record_hook_time(&plugin.name, start.elapsed()); + result + } + + /// Record hook call duration for a plugin (thread-safe) + pub fn record_hook_time(&self, plugin_name: &str, duration: Duration) { + if let Ok(mut timings) = self.hook_timings.lock() { + *timings.entry(plugin_name.to_string()).or_default() += duration; + } + } + + /// Get per-plugin hook timings (name -> total duration) + pub fn hook_timings(&self) -> HashMap { + self.hook_timings + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default() + } } impl Default for PluginManager { @@ -373,7 +409,7 @@ timeout_ms = 5000 let mgr = PluginManager::load(tmp.path()); let p = mgr.plugins().next().unwrap(); let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; - let result = p.call_hook(p.hooks.post_render.unwrap(), input); + let result = p.call_hook(p.hooks.post_render.unwrap(), input).unwrap(); assert!(result.is_some()); assert!(result.unwrap().contains("[transformed]")); } @@ -396,7 +432,7 @@ timeout_ms = 5000 let mgr = PluginManager::load(tmp.path()); let p = mgr.plugins().next().unwrap(); let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; - let result = p.call_hook(p.hooks.post_render.unwrap(), input); + let result = p.call_hook(p.hooks.post_render.unwrap(), input).unwrap(); assert_eq!(result, None); } @@ -418,9 +454,9 @@ timeout_ms = 5000 let mgr = PluginManager::load(tmp.path()); let p = mgr.plugins().next().unwrap(); let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; - // Should return None (timeout is logged as warning, hook returns None) + // Should return Err (timeout) let result = p.call_hook(p.hooks.post_render.unwrap(), input); - assert_eq!(result, None); + assert!(result.is_err()); } #[test] @@ -441,9 +477,9 @@ timeout_ms = 5000 let mgr = PluginManager::load(tmp.path()); let p = mgr.plugins().next().unwrap(); let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; - // Error response -> call_hook returns None (warning logged) + // Error response -> call_hook returns Err let result = p.call_hook(p.hooks.post_render.unwrap(), input); - assert_eq!(result, None); + assert!(result.is_err()); } #[test] @@ -495,7 +531,7 @@ timeout_ms = 5000 assert!(p.hooks.post_render.is_some(), "post_render hook should be set"); let input = r#"{"html":"

hello

","metadata":{},"rel_path":"test.norg"}"#; - let result = p.call_hook(p.hooks.post_render.unwrap(), input); + let result = p.call_hook(p.hooks.post_render.unwrap(), input).unwrap(); assert!(result.is_some(), "plugin should return modified HTML"); assert!(result.unwrap().contains(""), "should contain plugin marker"); } diff --git a/core/src/plugin/sandbox.rs b/core/src/plugin/sandbox.rs index 398b0be..a3b5e8e 100644 --- a/core/src/plugin/sandbox.rs +++ b/core/src/plugin/sandbox.rs @@ -13,6 +13,13 @@ use tracing::warn; /// /// Once applied, restrictions are irreversible for the process lifetime. /// On non-Linux platforms or without `sandbox-linux` feature, this is a no-op. +/// +/// # Known Limitation +/// Landlock is applied AFTER plugin loading (`dlopen`). Plugin `.so` constructors +/// (`init_array`) execute during loading, before restrictions are in place. +/// This means plugin init code runs with full filesystem access. +/// Mitigation: plugin init functions should be minimal (set hook pointers only). +/// A future version can fork before loading to isolate constructors. pub fn apply_landlock(site_dir: &Path) -> Result<()> { #[cfg(not(all(target_os = "linux", feature = "sandbox-linux")))] { From cf5295c2906c8faa22dba62d4d4747f16e8c0c17 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Sun, 28 Jun 2026 21:26:10 -0400 Subject: [PATCH 11/12] docs: document plugins --- docs/content/docs/plugins.norg | 345 +++++++++++++++++++++++++++++++++ sdk/README.md | 244 +++++++++++++++++++++++ 2 files changed, 589 insertions(+) create mode 100644 docs/content/docs/plugins.norg create mode 100644 sdk/README.md diff --git a/docs/content/docs/plugins.norg b/docs/content/docs/plugins.norg new file mode 100644 index 0000000..5f4f408 --- /dev/null +++ b/docs/content/docs/plugins.norg @@ -0,0 +1,345 @@ +@document.meta +title: Plugins +description: Extend Norgolith with plugins +authors: [ + NTBBloodbath +] +categories: [ + docs +] +created: 2026-06-28T00:00:00-04:00 +updated: 2026-06-28T00:00:00-04:00 +draft: false +layout: docs +version: 1.0.0 +@end + +Norgolith has a plugin system that lets you extend the build process. Plugins are shared libraries +written in Rust that hook into different stages of the build pipeline. They can transform content, +inject HTML, generate files, and more. + +This guide explains how plugins work, how to install them, and how to write your own. + +** How Plugins Work + +A plugin is a shared library (`.so`, `.dylib`, or `.dll`) with a `plugin.toml` manifest. When you +run `lith build`, Norgolith loads all plugins from the `plugins/` directory, checks their manifests, +and calls their hook functions at the right time in the build process. + +The build pipeline runs in this order: + +@code bash +Norg files -> [pre_build] -> parse + convert to HTML -> [post_convert] + -> Tera template -> [post_render] -> write to public/ -> [post_build] +@end + +Each plugin can implement one or more of these four hooks. The hooks receive JSON input with the +current page data, and return modified HTML or an error. + +** Quick Start + +Let's walk through creating a simple plugin that adds a watermark to every page. + +*** Scaffold the plugin + +Run the `lith plugin new` command to create the project structure: + +@code bash +lith plugin new watermark +@end + +This creates a `plugins/watermark/` directory with three files: + +@code bash +plugins/watermark/ ++-- plugin.toml # Plugin manifest ++-- Cargo.toml # Rust package config ++-- src/lib.rs # Your code goes here +@end + +*** Edit the hook + +Open `plugins/watermark/src/lib.rs` and replace its contents with: + +@code rust +use norgolith_plugin_sdk::*; + +register_plugin!("watermark", "0.1.0", + hooks: [post_render: add_watermark] +); + +fn add_watermark(json: serde_json::Value) -> Result, String> { + let ctx: TransformContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + + let watermark = ""; + Ok(Some(format!("{}\n{}", ctx.html, watermark))) +} +@end + +*** Build and install + +Build the plugin in release mode, then install it: + +@code bash +cd plugins/watermark +cargo build --release +cd ../.. +lith plugin install plugins/watermark +@end + +*** Test it + +Run `lith build` and check the output. Every page will now have the comment at the end. + +** Plugin Manifest + +Every plugin needs a `plugin.toml` file next to its shared library. Here is the full format: + +@code toml +[plugin] +name = "my-plugin" +version = "0.1.0" +norgolith = ">=0.4.0" +abi = 1 + +[hooks] +pre_build = false +post_convert = false +post_render = true +post_build = false + +[capabilities] +filesystem = "none" +network = false + +timeout_ms = 10000 +priority = 100 +@end + +*** \[plugin\] section + +- `name`: must match the name you pass to `register_plugin!` in your Rust code +- `version`: semantic version for your plugin +- `norgolith`: semver requirement for the Norgolith version this plugin supports (e.g. `>=0.4.0`) +- `abi`: must be `1`. This is the plugin ABI version and must match the core + +*** \[hooks\] section + +Set each hook to `true` if your plugin implements it: + +- `pre_build`: runs before any content is processed +- `post_convert`: runs after Norg-to-HTML conversion, before Tera templating +- `post_render`: runs after Tera layout is applied, before writing to disk +- `post_build`: runs after all pages are written + +*** \[capabilities\] section + +Declare what your plugin needs: + +- `filesystem`: `"none"`, `"read"`, `"write"`, or `"read-write"` +- `network`: `true` or `false` + +@embed html +
+ + + Note + +@end + +These are declarations only. Norgolith currently does not enforce filesystem or network restrictions +at runtime. Landlock sandboxing on Linux restricts filesystem access to the site directory. +@embed html +
+@end + +*** Other fields + +- `timeout_ms`: maximum time in milliseconds for a single hook call (default: 10000) +- `priority`: execution order when multiple plugins are installed. Lower numbers run first (default: + 100) + +** Hook Reference + +*** pre_build + +Runs once before any content is processed. Receives the full site configuration as JSON. Use this to +initialize state, load external data, or validate the environment. + +@code rust +fn init(json: serde_json::Value) -> Result, String> { + let ctx: PreBuildContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + + // ctx.site_config -> full site config as JSON + // ctx.pages_dir -> path to content/ directory + // ctx.output_dir -> path to public/ directory + + Ok(None) // Return Ok(None) if you have nothing to report +} +@end + +*** post_convert + +Runs after each page is converted from Norg to HTML, but before Tera templating. This is where you +transform the page content. The input is an HTML fragment (just the body content, no layout). + +@code rust +fn transform(json: serde_json::Value) -> Result, String> { + let ctx: TransformContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + + // ctx.html -> the HTML content + // ctx.metadata -> page metadata as JSON (title, date, tags, etc.) + // ctx.rel_path -> relative path (e.g. "posts/hello.norg") + + // Return the modified HTML + Ok(Some(ctx.html.replace("

", "

"))) +} +@end + +*** post_render + +Runs after Tera layout is applied. The input is the complete HTML page (including ``, +``, etc.). Use this to inject elements into `` or modify the final page. + +@code rust +fn inject(json: serde_json::Value) -> Result, String> { + let ctx: TransformContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + + // Inject before + if let Some(pos) = ctx.html.find("") { + let tag = r#""#; + let result = format!("{}{}\n{}", &ctx.html[..pos], tag, &ctx.html[pos..]); + return Ok(Some(result)); + } + + Ok(None) +} +@end + +*** post_build + +Runs once after all pages are written to disk. Use this to generate extra files or clean up. + +@code rust +fn finalize(json: serde_json::Value) -> Result, String> { + let ctx: PostBuildContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + + // Generate a file in the output directory + let path = std::path::Path::new(&ctx.output_dir).join("robots.txt"); + std::fs::write(&path, "User-agent: *\nAllow: /").ok(); + + Ok(None) +} +@end + +** CLI Commands + +*** lith plugin new \ + +Create a new plugin project with the basic structure. + +@code bash +lith plugin new my-plugin +@end + +*** lith plugin install \ + +Build a plugin from source and install it into your site's `plugins/` directory. + +@code bash +lith plugin install plugins/my-plugin +@end + +*** lith plugin list + +Show all installed plugins with their name, version, hooks, priority, and status. + +@code bash +lith plugin list +@end + +*** lith plugin uninstall \ + +Remove an installed plugin. + +@code bash +lith plugin uninstall my-plugin +@end + +** Plugin Ordering + +When you have multiple plugins installed, they run in the order specified by the `priority` field in +`plugin.toml`. Lower numbers run first. + +For example, if you have two plugins: + +@code toml +# Plugin A: priority 50 +priority = 50 + +# Plugin B: priority 100 +priority = 100 +@end + +Plugin A's hooks run before Plugin B's hooks. The default priority is 100 if you don't specify one. + +** Security + +Plugins run inside the Norgolith process. On Linux, Landlock restricts filesystem access to the site +directory and cache directory. Plugins cannot read or write files outside these paths. + +@embed html +

+ + + Known Limitation + +@end + +Network restrictions are not enforced right now. The `network` field in `plugin.toml` is a +declaration only. Plugins can open network connections regardless of this setting. + +@embed html +
+@end + +** Troubleshooting + +*** Plugin not loading + +- Check that `plugin.toml` exists next to the `.so` file +- Make sure the `abi` field in `plugin.toml` matches the current Norgolith version +- Check the build output for warnings about skipped plugins + +*** Plugin hook not running + +- Verify that the hook is set to `true` in `plugin.toml` +- Check that your `register_plugin!` macro includes the hook name + +*** Build is slow with plugins + +- Use the `priority` field to order plugins efficiently +- Check the build output for per-plugin timing information +- Consider whether your plugin needs to run on every page + +*** Error: "ABI mismatch" + +The plugin was compiled against a different ABI version. Rebuild the plugin from source with the +current Norgolith SDK. + +*** Error: "Version mismatch" + +The plugin requires a different Norgolith version. Check the `norgolith` field in `plugin.toml` and +update it if needed, or install a compatible version of the plugin. + +** Learn More + +- {/sdk/}[Plugin SDK on crates.io] - API reference for plugin developers +- {https://github.com/NTBBloodbath/norgolith}[Norgolith repository] - source code and issue tracker + +{:/docs/contributing:}[Next: Contributing ->] diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..5aee756 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,244 @@ +# Norgolith Plugin SDK + +SDK for building Norgolith plugins. Write a hook function, register it with a macro, and the plugin runs during the build process. + +For the full plugin guide, see [Plugins documentation](https://norgolith.amartin.beer/docs/plugins). + +## Adding the SDK + +Add the SDK to your `Cargo.toml`: + +```toml +[package] +name = "my-plugin" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +norgolith-plugin-sdk = "0.1" +``` + +The `cdylib` crate type is required so the output is a shared library (`.so` on Linux, `.dylib` on macOS, `.dll` on Windows). + +## A Minimal Plugin + +The quickest way to start is `lith plugin new my-plugin`, which generates: + +``` +plugins/my-plugin/ + plugin.toml # Plugin manifest + Cargo.toml # Rust package config + src/lib.rs # Your hook code +``` + +The generated `src/lib.rs` looks like this: + +```rust +use norgolith_plugin_sdk::*; + +register_plugin!("my-plugin", "0.1.0", + hooks: [post_render: my_hook] +); + +fn my_hook(json: serde_json::Value) -> Result, String> { + let ctx: TransformContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + Ok(Some(ctx.html)) +} +``` + +Build and install it: + +```bash +cd plugins/my-plugin +cargo build --release +cd ../.. +lith plugin install plugins/my-plugin +``` + +Now `lith build` runs your plugin on every page. + +## The `register_plugin!` Macro + +The macro generates two things: the shared library entry point and bridge functions for each hook. + +```rust +register_plugin!("plugin-name", "0.1.0", + hooks: [hook_name: handler_function, ...] +); +``` + +- First argument: plugin name (must match `plugin.toml`) +- Second argument: version string +- `hooks:` list: maps hook names to your handler functions + +Valid hook names are `pre_build`, `post_convert`, `post_render`, and `post_build`. + +## Hook Types + +Each hook runs at a different point in the build process: + +| Hook | When it runs | Input | Use case | +| -------------- | -------------------------------------------- | ------------------------ | ----------------------------------------------- | +| `pre_build` | Before any content is processed | Site config | Initialize plugin state, load external data | +| `post_convert` | After Norg-to-HTML, before Tera templating | HTML fragment + metadata | Modify page content (e.g., syntax highlighting) | +| `post_render` | After Tera layout is applied, before writing | Final HTML page | Inject into `` or `` | +| `post_build` | After all pages are written to disk | Site config | Generate extra files (e.g., sitemaps) | + +## Context Types + +Each hook receives a JSON string. Deserialize it into the matching context type: + +### `PreBuildContext` + +Available in `pre_build`: + +```rust +#[derive(serde::Deserialize)] +pub struct PreBuildContext { + pub site_config: serde_json::Value, // Full site config as JSON + pub pages_dir: String, // Path to content/ directory + pub output_dir: String, // Path to public/ directory +} +``` + +### `TransformContext` + +Available in `post_convert` and `post_render`: + +```rust +#[derive(serde::Deserialize)] +pub struct TransformContext { + pub html: String, // The HTML content + pub metadata: serde_json::Value, // Page metadata (title, date, etc.) + pub rel_path: String, // Relative path (e.g., "posts/hello.norg") +} +``` + +### `PostBuildContext` + +Available in `post_build`: + +```rust +#[derive(serde::Deserialize)] +pub struct PostBuildContext { + pub site_config: serde_json::Value, + pub pages_dir: String, + pub output_dir: String, +} +``` + +## Return Values + +Your handler function returns `Result, String>`: + +- `Ok(Some(html))`: return modified content. The new HTML replaces the original. +- `Ok(None)`: no change. The page passes through unmodified. +- `Err(message)`: an error occurred. The error is logged and the page passes through unchanged. + +```rust +fn my_hook(json: serde_json::Value) -> Result, String> { + let ctx: TransformContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + + // Only modify pages with a specific tag + if ctx.metadata.get("tags") + .and_then(|v| v.as_array()) + .map(|tags| tags.iter().any(|t| t.as_str() == Some("special"))) + .unwrap_or(false) + { + Ok(Some(format!("\n{}", ctx.html))) + } else { + Ok(None) // Leave other pages alone + } +} +``` + +## Building + +Build your plugin in release mode: + +```bash +cargo build --release +``` + +The output shared library is in `target/release/`: + +- Linux: `libmy-plugin.so` +- macOS: `libmy-plugin.dylib` +- Windows: `my-plugin.dll` + +Install it: + +```bash +lith plugin install plugins/my-plugin +``` + +This copies the `.so` and `plugin.toml` into your site's `plugins/` directory. + +## Plugin Manifest (`plugin.toml`) + +Every plugin needs a `plugin.toml` alongside the shared library: + +```toml +[plugin] +name = "my-plugin" +version = "0.1.0" +norgolith = ">=0.4.0" # Semver requirement for norgolith version +abi = 1 # Must match CORE_ABI_VERSION + +[hooks] +pre_build = false +post_convert = false +post_render = true # Set to true for hooks you implement +post_build = false + +[capabilities] +filesystem = "none" # "none", "read", "write", "read-write" +network = false # Whether plugin needs network access + +timeout_ms = 10000 # Max time per hook call (default: 10000ms) +priority = 100 # Lower numbers run first (default: 100) +``` + +## Priority + +Use the `priority` field to control execution order when you have multiple plugins. Lower numbers run first: + +```toml +priority = 50 # Runs early +priority = 100 # Default, runs after priority 50 +priority = 200 # Runs last +``` + +## Full Example + +Here is a complete plugin that wraps every `

` heading in a styled div: + +```rust +use norgolith_plugin_sdk::*; + +register_plugin!("heading-wrapper", "0.1.0", + hooks: [post_render: wrap_headings] +); + +fn wrap_headings(json: serde_json::Value) -> Result, String> { + let ctx: TransformContext = serde_json::from_value(json) + .map_err(|e| e.to_string())?; + + let html = ctx.html.replace( + "

", + r#"

"#, + ); + + Ok(Some(html)) +} +``` + +## See Also + +- [Plugin system guide](https://norgolith.amartin.beer/docs/plugins): full documentation for users and plugin authors +- [Norgolith repository](https://github.com/NTBBloodbath/norgolith): source code and issue tracker From 11268cb750a9f2b0fdd9474691fde9d236264939 Mon Sep 17 00:00:00 2001 From: NTBBloodbath Date: Mon, 29 Jun 2026 15:33:00 -0400 Subject: [PATCH 12/12] fix: rewrite plugin list output with vertical per-plugin layout - Name on its own line, version/status/hooks/priority/abi/norgolith/timeout/fs/net below - Status shows "ok" (green) or "no hooks" (yellow) when no hooks declared - Empty state improved with installation hints --- core/src/cmd/plugin.rs | 74 +++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/core/src/cmd/plugin.rs b/core/src/cmd/plugin.rs index d811689..f6dbd79 100644 --- a/core/src/cmd/plugin.rs +++ b/core/src/cmd/plugin.rs @@ -50,46 +50,54 @@ fn list_plugins() -> Result<()> { return Ok(()); } - println!( - "{:<20} {:<10} {:<8} {:<30} {}", - "NAME".bold(), - "VERSION".bold(), - "PRI".bold(), - "HOOKS".bold(), - "STATUS".bold() - ); - println!("{}", "-".repeat(85)); - for p in mgr.plugins() { - let hooks = { - let mut h = Vec::new(); - if p.manifest.hooks.pre_build { - h.push("pre_build"); - } - if p.manifest.hooks.post_convert { - h.push("post_convert"); - } - if p.manifest.hooks.post_render { - h.push("post_render"); - } - if p.manifest.hooks.post_build { - h.push("post_build"); - } - if h.is_empty() { - "none".dimmed().to_string() - } else { - h.join(", ") - } + let hooks = p.manifest.hooks.declared_hooks(); + let hooks_str = if hooks.is_empty() { + "none".dimmed().to_string() + } else { + hooks.join(", ") + }; + + let status = if hooks.is_empty() { + "no hooks".yellow().to_string() + } else { + "ok".green().to_string() }; - let status = "ok".green(); + let fs = match p.manifest.capabilities.filesystem { + plugin::FilesystemAccess::None => "none".dimmed().to_string(), + plugin::FilesystemAccess::Read => "read".to_string(), + plugin::FilesystemAccess::Write => "write".to_string(), + plugin::FilesystemAccess::ReadWrite => "read-write".to_string(), + }; + let net = if p.manifest.capabilities.network { + "yes".to_string() + } else { + "no".dimmed().to_string() + }; + + println!("{}", p.name.bold()); + println!( + " version: {:<10} hooks: {}", + p.version, hooks_str + ); + println!( + " status: {:<19} priority: {}", + status, p.manifest.priority + ); + println!( + " abi: {:<10} norgolith: {}", + p.manifest.plugin.abi, p.manifest.plugin.norgolith + ); println!( - "{:<20} {:<10} {:<8} {:<30} {}", - p.name, p.version, p.manifest.priority, hooks, status + " timeout: {:<10} fs: {} net: {}", + format!("{}s", p.manifest.timeout_ms / 1000), + fs, + net ); } - println!("\n{} plugin(s) loaded", mgr.len()); + println!("\n{}", format!("{} plugin(s) loaded", mgr.len()).bold()); Ok(()) }