1use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5use std::ffi::OsString;
6use std::fs;
7use std::io::{self, Read, Write};
8use std::os::fd::AsRawFd;
9use std::path::{Path, PathBuf};
10use std::process::{Command, ExitStatus, Stdio};
11use uuid::{Uuid, Version};
12
13pub const MANIFEST_SCHEMA: u32 = 1;
14pub const MANIFEST_BYTES_LIMIT: u64 = 64 * 1024;
15pub const PLUGIN_LIMIT: usize = 64;
16const PLUGIN_DIRECTORY_ENTRY_LIMIT: usize = 4096;
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(deny_unknown_fields)]
20pub struct PluginManifest {
21 pub schema: u32,
22 pub id: Uuid,
23 pub name: String,
24 pub version: String,
25 pub executable: PathBuf,
26 pub description: String,
27 pub capabilities: Vec<String>,
28}
29
30impl PluginManifest {
31 pub fn validate(&self) -> io::Result<()> {
32 if self.schema != MANIFEST_SCHEMA {
33 return Err(invalid_data("unsupported plugin manifest schema"));
34 }
35 if self.id.get_version() != Some(Version::SortRand) {
36 return Err(invalid_data("plugin ID must be a UUIDv7"));
37 }
38 validate_text(&self.name, "plugin name", 80)?;
39 validate_text(&self.version, "plugin version", 32)?;
40 validate_text(&self.description, "plugin description", 280)?;
41 if !self.executable.is_absolute() {
42 return Err(invalid_data("plugin executable must be an absolute path"));
43 }
44 if self.capabilities.is_empty() || self.capabilities.len() > 16 {
45 return Err(invalid_data("plugin must declare 1 to 16 capabilities"));
46 }
47 let mut seen = BTreeSet::new();
48 for capability in &self.capabilities {
49 validate_token(capability, "plugin capability")?;
50 if !seen.insert(capability) {
51 return Err(invalid_data("plugin capabilities must be unique"));
52 }
53 }
54 Ok(())
55 }
56
57 pub fn supports(&self, capability: &str) -> bool {
58 self.capabilities
59 .iter()
60 .any(|candidate| candidate == capability)
61 }
62}
63
64pub fn plugin_data_dir() -> PathBuf {
65 std::env::var_os("XDG_DATA_HOME")
66 .map(PathBuf::from)
67 .unwrap_or_else(|| home_dir().join(".local/share"))
68 .join("guth/plugins")
69}
70
71pub fn discover_plugins() -> io::Result<Vec<PluginManifest>> {
72 discover_plugins_in(&plugin_data_dir())
73}
74
75pub fn discover_plugins_in(directory: &Path) -> io::Result<Vec<PluginManifest>> {
76 let mut manifests = Vec::new();
77 let entries = match fs::read_dir(directory) {
78 Ok(entries) => entries,
79 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(manifests),
80 Err(error) => return Err(error),
81 };
82 let mut paths = Vec::new();
83 for (index, entry) in entries.enumerate() {
84 if index >= PLUGIN_DIRECTORY_ENTRY_LIMIT {
85 return Err(invalid_data("plugin directory entry limit exceeded"));
86 }
87 let Ok(entry) = entry else {
88 continue;
89 };
90 let path = entry.path();
91 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
92 continue;
93 };
94 let Ok(id) = Uuid::parse_str(stem) else {
95 continue;
96 };
97 if id.get_version() == Some(Version::SortRand)
98 && id.to_string() == stem
99 && path
100 .extension()
101 .is_some_and(|extension| extension == "json")
102 {
103 paths.push((id, path));
104 }
105 }
106 paths.sort();
107 for (expected_id, path) in paths.into_iter().take(PLUGIN_LIMIT) {
108 let Ok(file) = open_verified_file(&path, false, true) else {
109 continue;
110 };
111 let Ok(metadata) = file.metadata() else {
112 continue;
113 };
114 if metadata.len() > MANIFEST_BYTES_LIMIT {
115 continue;
116 }
117 let mut bytes = Vec::with_capacity(metadata.len() as usize);
118 if file
119 .take(MANIFEST_BYTES_LIMIT + 1)
120 .read_to_end(&mut bytes)
121 .is_err()
122 {
123 continue;
124 }
125 let Ok(manifest) = serde_json::from_slice::<PluginManifest>(&bytes) else {
126 continue;
127 };
128 if manifest.id != expected_id
129 || manifest.validate().is_err()
130 || open_verified_file(&manifest.executable, true, false).is_err()
131 {
132 continue;
133 }
134 manifests.push(manifest);
135 }
136 manifests.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
137 manifests.dedup_by_key(|manifest| manifest.id);
138 Ok(manifests)
139}
140
141pub fn install_manifest(manifest: &PluginManifest) -> io::Result<PathBuf> {
142 install_manifest_in(manifest, &plugin_data_dir())
143}
144
145pub fn install_manifest_in(manifest: &PluginManifest, directory: &Path) -> io::Result<PathBuf> {
146 manifest.validate()?;
147 open_verified_file(&manifest.executable, true, false)?;
148 if let Some(parent) = directory.parent() {
149 create_private_dir(parent)?;
150 }
151 let directory_file = create_private_dir(directory)?;
152 rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
153 .map_err(errno_error)?;
154 let destination = directory.join(format!("{}.json", manifest.id));
155 let destination_name = format!("{}.json", manifest.id);
156 let temporary_name = format!(".{}.{}.tmp", manifest.id, Uuid::now_v7());
157 if !destination.exists() && manifest_count(directory)? >= PLUGIN_LIMIT {
158 return Err(invalid_data("plugin limit reached"));
159 }
160 let bytes = serde_json::to_vec_pretty(manifest).map_err(invalid_json)?;
161 let result = (|| {
162 let owned_fd = rustix::fs::openat(
163 &directory_file,
164 temporary_name.as_str(),
165 rustix::fs::OFlags::WRONLY
166 | rustix::fs::OFlags::CREATE
167 | rustix::fs::OFlags::EXCL
168 | rustix::fs::OFlags::NOFOLLOW
169 | rustix::fs::OFlags::CLOEXEC,
170 rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
171 )
172 .map_err(errno_error)?;
173 let mut file = fs::File::from(owned_fd);
174 file.write_all(&bytes)?;
175 file.write_all(b"\n")?;
176 file.sync_all()?;
177 drop(file);
178 rustix::fs::renameat(
179 &directory_file,
180 temporary_name.as_str(),
181 &directory_file,
182 destination_name.as_str(),
183 )
184 .map_err(errno_error)?;
185 directory_file.sync_all()?;
186 Ok(destination.clone())
187 })();
188 if result.is_err() {
189 let _ = rustix::fs::unlinkat(
190 &directory_file,
191 temporary_name.as_str(),
192 rustix::fs::AtFlags::empty(),
193 );
194 }
195 result
196}
197
198pub fn run_plugin(
199 manifest: &PluginManifest,
200 arguments: impl IntoIterator<Item = OsString>,
201) -> io::Result<ExitStatus> {
202 manifest.validate()?;
203 let executable = open_verified_file(&manifest.executable, true, false)?;
204 rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
205 let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
206 Command::new(executable_path)
207 .args(arguments)
208 .stdin(Stdio::inherit())
209 .stdout(Stdio::inherit())
210 .stderr(Stdio::inherit())
211 .status()
212}
213
214fn validate_text(value: &str, label: &str, max_len: usize) -> io::Result<()> {
215 if value.is_empty()
216 || value.len() > max_len
217 || value.chars().any(|character| character.is_control())
218 {
219 return Err(invalid_data(format!("invalid {label}")));
220 }
221 Ok(())
222}
223
224fn validate_token(value: &str, label: &str) -> io::Result<()> {
225 if value.is_empty()
226 || value.len() > 40
227 || !value
228 .bytes()
229 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
230 {
231 return Err(invalid_data(format!("invalid {label}")));
232 }
233 Ok(())
234}
235
236fn open_verified_file(path: &Path, executable: bool, private: bool) -> io::Result<fs::File> {
237 let owned_fd = rustix::fs::open(
238 path,
239 rustix::fs::OFlags::RDONLY
240 | rustix::fs::OFlags::CLOEXEC
241 | rustix::fs::OFlags::NOFOLLOW
242 | rustix::fs::OFlags::NONBLOCK,
243 rustix::fs::Mode::empty(),
244 )
245 .map_err(errno_error)?;
246 let file = fs::File::from(owned_fd);
247 let metadata = file.metadata()?;
248 if !metadata.is_file() {
249 return Err(invalid_data("plugin path must be a regular file"));
250 }
251 #[cfg(unix)]
252 {
253 use std::os::unix::fs::MetadataExt;
254 let current_uid = rustix::process::geteuid().as_raw();
255 if metadata.uid() != current_uid && metadata.uid() != 0 {
256 return Err(io::Error::new(
257 io::ErrorKind::PermissionDenied,
258 "plugin file has an untrusted owner",
259 ));
260 }
261 let unsafe_permissions = if private { 0o077 } else { 0o022 };
262 if metadata.mode() & unsafe_permissions != 0 {
263 return Err(io::Error::new(
264 io::ErrorKind::PermissionDenied,
265 "plugin file has unsafe permissions",
266 ));
267 }
268 if executable && metadata.mode() & 0o111 == 0 {
269 return Err(io::Error::new(
270 io::ErrorKind::PermissionDenied,
271 "plugin executable is not executable",
272 ));
273 }
274 }
275 Ok(file)
276}
277
278fn create_private_dir(path: &Path) -> io::Result<fs::File> {
279 if !path.is_absolute() {
280 return Err(invalid_data("plugin directory must be absolute"));
281 }
282 fs::create_dir_all(path)?;
283 let owned_fd = rustix::fs::open(
284 path,
285 rustix::fs::OFlags::RDONLY
286 | rustix::fs::OFlags::DIRECTORY
287 | rustix::fs::OFlags::NOFOLLOW
288 | rustix::fs::OFlags::CLOEXEC,
289 rustix::fs::Mode::empty(),
290 )
291 .map_err(errno_error)?;
292 let directory = fs::File::from(owned_fd);
293 #[cfg(unix)]
294 {
295 use std::os::unix::fs::MetadataExt;
296 let metadata = directory.metadata()?;
297 if !metadata.is_dir() {
298 return Err(io::Error::new(
299 io::ErrorKind::PermissionDenied,
300 "plugin directory must be a real directory",
301 ));
302 }
303 if metadata.uid() != rustix::process::geteuid().as_raw() {
304 return Err(io::Error::new(
305 io::ErrorKind::PermissionDenied,
306 "plugin directory has an unexpected owner",
307 ));
308 }
309 rustix::fs::fchmod(&directory, rustix::fs::Mode::RWXU).map_err(errno_error)?;
310 }
311 Ok(directory)
312}
313
314fn manifest_count(directory: &Path) -> io::Result<usize> {
315 let mut count = 0;
316 for entry in fs::read_dir(directory)?.take(PLUGIN_DIRECTORY_ENTRY_LIMIT) {
317 let Ok(entry) = entry else {
318 continue;
319 };
320 let path = entry.path();
321 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
322 continue;
323 };
324 if path
325 .extension()
326 .is_some_and(|extension| extension == "json")
327 && Uuid::parse_str(stem).is_ok_and(|id| {
328 id.get_version() == Some(Version::SortRand) && id.to_string() == stem
329 })
330 {
331 count += 1;
332 }
333 }
334 Ok(count)
335}
336
337fn home_dir() -> PathBuf {
338 std::env::var_os("HOME")
339 .map(PathBuf::from)
340 .unwrap_or_else(|| PathBuf::from("/"))
341}
342
343fn invalid_data(message: impl Into<String>) -> io::Error {
344 io::Error::new(io::ErrorKind::InvalidData, message.into())
345}
346
347fn invalid_json(error: impl std::fmt::Display) -> io::Error {
348 invalid_data(format!("invalid plugin manifest: {error}"))
349}
350
351fn errno_error(error: rustix::io::Errno) -> io::Error {
352 io::Error::from_raw_os_error(error.raw_os_error())
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use std::time::{SystemTime, UNIX_EPOCH};
359
360 struct TestDir(PathBuf);
361
362 impl TestDir {
363 fn new(label: &str) -> Self {
364 let nonce = SystemTime::now()
365 .duration_since(UNIX_EPOCH)
366 .unwrap()
367 .as_nanos();
368 let path = std::env::temp_dir()
369 .join(format!("guth-cli-{label}-{}-{nonce}", std::process::id()));
370 fs::create_dir(&path).unwrap();
371 Self(path)
372 }
373 }
374
375 impl Drop for TestDir {
376 fn drop(&mut self) {
377 let _ = fs::remove_dir_all(&self.0);
378 }
379 }
380
381 fn test_manifest(executable: PathBuf) -> PluginManifest {
382 PluginManifest {
383 schema: MANIFEST_SCHEMA,
384 id: Uuid::now_v7(),
385 name: "Test Sync".to_string(),
386 version: "0.1.0".to_string(),
387 executable,
388 description: "A test plugin".to_string(),
389 capabilities: vec!["sync".to_string()],
390 }
391 }
392
393 #[test]
394 fn manifests_require_uuid_v7_and_absolute_executables() {
395 let mut manifest = test_manifest(PathBuf::from("relative"));
396 assert!(manifest.validate().is_err());
397 manifest.executable = PathBuf::from("/bin/true");
398 manifest.id = Uuid::nil();
399 assert!(manifest.validate().is_err());
400 }
401
402 #[cfg(unix)]
403 #[test]
404 fn installed_manifests_are_private_and_discoverable() {
405 use std::os::unix::fs::{MetadataExt, PermissionsExt};
406
407 let root = TestDir::new("discovery");
408 let executable = root.0.join("plugin");
409 fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
410 fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
411 let manifest = test_manifest(executable);
412 let directory = root.0.join("manifests");
413
414 let installed = install_manifest_in(&manifest, &directory).unwrap();
415 assert_eq!(fs::metadata(&directory).unwrap().mode() & 0o777, 0o700);
416 assert_eq!(fs::metadata(&installed).unwrap().mode() & 0o777, 0o600);
417 assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
418 }
419
420 #[cfg(unix)]
421 #[test]
422 fn writable_executables_are_rejected() {
423 use std::os::unix::fs::PermissionsExt;
424
425 let root = TestDir::new("permissions");
426 let executable = root.0.join("plugin");
427 fs::write(&executable, b"plugin").unwrap();
428 fs::set_permissions(&executable, fs::Permissions::from_mode(0o722)).unwrap();
429 let error = open_verified_file(&executable, true, false).unwrap_err();
430 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
431 }
432
433 #[cfg(unix)]
434 #[test]
435 fn malformed_neighbors_do_not_hide_valid_plugins() {
436 use std::os::unix::fs::PermissionsExt;
437
438 let root = TestDir::new("malformed-neighbor");
439 let executable = root.0.join("plugin");
440 fs::write(&executable, b"plugin").unwrap();
441 fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
442 let manifest = test_manifest(executable);
443 let directory = root.0.join("manifests");
444 install_manifest_in(&manifest, &directory).unwrap();
445 let malformed = directory.join(format!("{}.json", Uuid::now_v7()));
446 fs::write(&malformed, b"not json").unwrap();
447 fs::set_permissions(&malformed, fs::Permissions::from_mode(0o600)).unwrap();
448
449 assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
450 }
451
452 #[cfg(unix)]
453 #[test]
454 fn special_file_candidates_do_not_block_discovery() {
455 let root = TestDir::new("special-file");
456 let fifo = root.0.join(format!("{}.json", Uuid::now_v7()));
457 rustix::fs::mkfifoat(
458 rustix::fs::CWD,
459 &fifo,
460 rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
461 )
462 .unwrap();
463
464 assert!(discover_plugins_in(&root.0).unwrap().is_empty());
465 }
466}