Skip to main content

max / audiofiles

5.6 KB · 191 lines History Blame Raw
1 //! Filesystem discovery: scan a directory for plugin `manifest.toml` files.
2
3 use std::path::Path;
4
5 use tracing::instrument;
6
7 use crate::error::PluginError;
8 use crate::manifest::{parse_manifest, PluginManifest};
9
10 /// A discovered plugin: its manifest and the directory it lives in.
11 pub struct DiscoveredPlugin {
12 /// Parsed TOML manifest.
13 pub manifest: PluginManifest,
14 /// Directory containing the manifest (for resolving relative hook paths).
15 pub plugin_dir: std::path::PathBuf,
16 }
17
18 /// Scan a directory for plugin subdirectories, each containing a `manifest.toml`.
19 ///
20 /// Returns all successfully parsed plugins. Invalid plugins are silently skipped
21 /// (the caller can log warnings if needed by checking the count).
22 #[instrument(skip_all, fields(dir = ?dir))]
23 pub fn discover_plugins(dir: &Path) -> Vec<DiscoveredPlugin> {
24 let mut plugins = Vec::new();
25
26 let entries = match std::fs::read_dir(dir) {
27 Ok(e) => e,
28 Err(_) => return plugins,
29 };
30
31 for entry in entries.flatten() {
32 let path = entry.path();
33 if !path.is_dir() {
34 continue;
35 }
36
37 let manifest_path = path.join("manifest.toml");
38 if !manifest_path.exists() {
39 continue;
40 }
41
42 let toml_str = match std::fs::read_to_string(&manifest_path) {
43 Ok(s) => s,
44 Err(e) => {
45 tracing::warn!(path = %manifest_path.display(), "failed to read plugin manifest: {e}");
46 continue;
47 }
48 };
49
50 match parse_manifest(&toml_str) {
51 Ok(manifest) => {
52 plugins.push(DiscoveredPlugin {
53 manifest,
54 plugin_dir: path,
55 });
56 }
57 Err(e) => {
58 tracing::warn!(path = %manifest_path.display(), "invalid plugin manifest: {e}");
59 continue;
60 }
61 }
62 }
63
64 plugins
65 }
66
67 /// Load a Rhai hook script from a plugin directory by relative path.
68 ///
69 /// Validates that the resolved path does not escape the plugin directory
70 /// (prevents path traversal via `..` in manifest-supplied paths).
71 #[instrument(skip_all, fields(path = ?plugin_dir))]
72 pub fn load_hook_script(
73 plugin_dir: &Path,
74 relative_path: &str,
75 ) -> Result<String, PluginError> {
76 let full_path = plugin_dir.join(relative_path);
77 let canonical = full_path.canonicalize().map_err(PluginError::Io)?;
78 let canonical_base = plugin_dir.canonicalize().map_err(PluginError::Io)?;
79 if !canonical.starts_with(&canonical_base) {
80 return Err(PluginError::Io(std::io::Error::new(
81 std::io::ErrorKind::PermissionDenied,
82 format!("hook script path escapes plugin directory: {relative_path}"),
83 )));
84 }
85 std::fs::read_to_string(&canonical).map_err(PluginError::Io)
86 }
87
88 #[cfg(test)]
89 mod tests {
90 use super::*;
91
92 #[test]
93 fn discover_empty_dir() {
94 let dir = tempfile::tempdir().unwrap();
95 let plugins = discover_plugins(dir.path());
96 assert!(plugins.is_empty());
97 }
98
99 #[test]
100 fn discover_valid_plugin() {
101 let dir = tempfile::tempdir().unwrap();
102 let plugin_dir = dir.path().join("my-device");
103 std::fs::create_dir(&plugin_dir).unwrap();
104 std::fs::write(
105 plugin_dir.join("manifest.toml"),
106 r#"
107 [device]
108 name = "Test Device"
109 manufacturer = "Test"
110 version = "1.0"
111
112 [audio]
113 formats = ["wav"]
114 sample_rates = [44100]
115 bit_depths = [16]
116 channels = "both"
117 "#,
118 )
119 .unwrap();
120
121 let plugins = discover_plugins(dir.path());
122 assert_eq!(plugins.len(), 1);
123 assert_eq!(plugins[0].manifest.device.name, "Test Device");
124 }
125
126 #[test]
127 fn discover_skips_invalid_manifest() {
128 let dir = tempfile::tempdir().unwrap();
129
130 // Valid plugin
131 let valid_dir = dir.path().join("valid");
132 std::fs::create_dir(&valid_dir).unwrap();
133 std::fs::write(
134 valid_dir.join("manifest.toml"),
135 r#"
136 [device]
137 name = "Valid"
138 manufacturer = "Test"
139 version = "1.0"
140 [audio]
141 formats = ["wav"]
142 sample_rates = [44100]
143 bit_depths = [16]
144 channels = "both"
145 "#,
146 )
147 .unwrap();
148
149 // Invalid plugin (bad TOML)
150 let invalid_dir = dir.path().join("invalid");
151 std::fs::create_dir(&invalid_dir).unwrap();
152 std::fs::write(invalid_dir.join("manifest.toml"), "not valid toml {{").unwrap();
153
154 let plugins = discover_plugins(dir.path());
155 assert_eq!(plugins.len(), 1);
156 assert_eq!(plugins[0].manifest.device.name, "Valid");
157 }
158
159 #[test]
160 fn load_hook_script_works() {
161 let dir = tempfile::tempdir().unwrap();
162 let hooks_dir = dir.path().join("hooks");
163 std::fs::create_dir(&hooks_dir).unwrap();
164 std::fs::write(hooks_dir.join("validate.rhai"), "info.channels == 1").unwrap();
165
166 let script = load_hook_script(dir.path(), "hooks/validate.rhai").unwrap();
167 assert_eq!(script, "info.channels == 1");
168 }
169
170 #[test]
171 fn load_hook_script_missing_file() {
172 let dir = tempfile::tempdir().unwrap();
173 let result = load_hook_script(dir.path(), "nonexistent.rhai");
174 assert!(result.is_err());
175 }
176
177 #[test]
178 fn load_hook_script_rejects_path_traversal() {
179 let dir = tempfile::tempdir().unwrap();
180 // Create a file outside the plugin dir
181 let outside = dir.path().join("secret.txt");
182 std::fs::write(&outside, "sensitive data").unwrap();
183
184 let plugin_dir = dir.path().join("my-plugin");
185 std::fs::create_dir(&plugin_dir).unwrap();
186
187 let result = load_hook_script(&plugin_dir, "../secret.txt");
188 assert!(result.is_err(), "path traversal should be rejected");
189 }
190 }
191