1use std::collections::{BTreeMap, BTreeSet};
4use std::ffi::OsString;
5use std::fs;
6use std::io::Read as _;
7use std::path::{Component, Path, PathBuf};
8use std::process::Stdio;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use mobius::middleware::extensions::{
13 ExtensionHook, ExtensionPackageKind, HookAuthorization, MANIFEST, inspect_package,
14 valid_package_name,
15};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest as _, Sha256};
18use tokio::process::Command;
19use url::Url;
20
21use crate::config::{ConfigStore, GatewayConfig};
22use crate::wire::{ExtensionHookRecord, ExtensionKind, ExtensionRecord};
23use crate::{Error, Result};
24
25const MAX_EXTENSIONS: usize = 64;
26const MAX_SOURCE_BYTES: usize = 4_096;
27const MAX_REFERENCE_BYTES: usize = 256;
28const MAX_SUBDIRECTORY_BYTES: usize = 1_024;
29const MAX_PACKAGE_FILES: usize = 4_096;
30const MAX_PACKAGE_BYTES: u64 = 64 * 1024 * 1024;
31const MAX_PATH_BYTES: usize = 4_096;
32const GIT_TIMEOUT: Duration = Duration::from_secs(120);
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub(crate) struct ExtensionSource {
37 pub(crate) url: String,
38 pub(crate) reference: Option<String>,
39 pub(crate) subdirectory: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub(crate) struct InstalledExtension {
45 pub(crate) kind: ExtensionKind,
46 pub(crate) name: String,
47 pub(crate) description: String,
48 pub(crate) version: Option<String>,
49 pub(crate) source: ExtensionSource,
50 pub(crate) resolved_revision: String,
51 pub(crate) digest: String,
52 pub(crate) skills: Vec<String>,
53 pub(crate) hooks: Vec<ExtensionHookRecord>,
54 pub(crate) trusted_hook_digest: Option<String>,
55}
56
57pub(crate) struct StagedExtension {
58 pub(crate) id: String,
59 pub(crate) installed: InstalledExtension,
60 pub(crate) snapshot_created: bool,
61}
62
63#[derive(Default)]
64pub(crate) struct ResolvedExtensions {
65 pub(crate) skill_roots: Vec<PathBuf>,
66 pub(crate) plugins: Vec<ResolvedPlugin>,
67}
68
69pub(crate) struct ResolvedPlugin {
70 pub(crate) id: String,
71 pub(crate) digest: String,
72 pub(crate) root: PathBuf,
73 pub(crate) hooks_trusted: bool,
74}
75
76impl ResolvedPlugin {
77 pub(crate) fn activation(
78 &self,
79 gateway: Arc<Mutex<GatewayConfig>>,
80 ) -> (PathBuf, Option<HookAuthorization>) {
81 let id = self.id.clone();
82 let digest = self.digest.clone();
83 let authorization = self.hooks_trusted.then(|| {
84 Arc::new(move |launch: &mut dyn FnMut() -> mobius::Result<()>| {
85 let Ok(config) = gateway.lock() else {
86 return Ok(());
87 };
88 if config
89 .installed_extensions
90 .get(&id)
91 .is_some_and(|installed| {
92 installed.digest == digest
93 && installed.trusted_hook_digest.as_deref() == Some(&digest)
94 })
95 {
96 launch()?;
97 }
98 Ok(())
99 }) as HookAuthorization
100 });
101 (self.root.clone(), authorization)
102 }
103}
104
105#[derive(Clone)]
106pub(crate) struct ExtensionStore {
107 root: PathBuf,
108}
109
110impl ExtensionStore {
111 pub(crate) fn new(store: &ConfigStore) -> Self {
112 Self {
113 root: store.extensions_path(),
114 }
115 }
116
117 pub(crate) async fn stage(
118 &self,
119 url: &str,
120 reference: Option<&str>,
121 subdirectory: Option<&str>,
122 ) -> Result<StagedExtension> {
123 let source = ExtensionSource::parse(url, reference, subdirectory)?;
124 prepare_private_directory(&self.root)?;
125 let staging = tempfile::Builder::new()
126 .prefix("stage-")
127 .tempdir_in(&self.root)?;
128 let checkout = staging.path().join("checkout");
129 clone_source(&source, &checkout).await?;
130 let revision = git_revision(&checkout).await?;
131 let selected = confined_checkout_path(&checkout, source.subdirectory.as_deref())?;
132 let package = staging.path().join("package");
133 tokio::task::spawn_blocking(move || export_package(&selected, &package))
134 .await
135 .map_err(|error| Error::Config(format!("extension export failed: {error}")))??;
136 let package = staging.path().join("package");
137 let inspected = inspect_package(&package)?;
138 let kind = inspected.kind.into();
139 let id = extension_id(kind, &inspected.name);
140 let digest = tree_digest(&package)?;
141 let snapshot = self.snapshot_root(&digest);
142 let parent = snapshot
143 .parent()
144 .ok_or_else(|| Error::Config("extension snapshot has no parent directory".into()))?;
145 let created = !snapshot.exists();
146 if !created {
147 verify_snapshot(&snapshot, &digest)?;
148 } else {
149 fs::create_dir_all(parent)?;
150 fs::rename(&package, &snapshot)?;
151 }
152 if let Err(error) = freeze_tree(parent) {
153 if created {
154 let _ = thaw_tree(parent);
155 let _ = fs::remove_dir_all(parent);
156 }
157 return Err(error);
158 }
159 Ok(StagedExtension {
160 id,
161 installed: InstalledExtension {
162 kind,
163 name: inspected.name,
164 description: inspected.description,
165 version: inspected.version,
166 source,
167 resolved_revision: revision,
168 digest,
169 skills: inspected.skills,
170 hooks: inspected.hooks.into_iter().map(Into::into).collect(),
171 trusted_hook_digest: None,
172 },
173 snapshot_created: created,
174 })
175 }
176
177 pub(crate) fn resolve(
178 &self,
179 config: &GatewayConfig,
180 ids: &BTreeSet<String>,
181 ) -> Result<ResolvedExtensions> {
182 validate_ids(ids)?;
183 let mut resolved = ResolvedExtensions::default();
184 for id in ids {
185 let Some(installed) = config.installed_extensions.get(id) else {
186 continue;
187 };
188 let package = self.snapshot_root(&installed.digest);
189 match installed.kind {
190 ExtensionKind::Skill => resolved.skill_roots.push(
191 package
192 .parent()
193 .ok_or_else(|| Error::Config("skill snapshot has no parent".into()))?
194 .to_path_buf(),
195 ),
196 ExtensionKind::Plugin => resolved.plugins.push(ResolvedPlugin {
197 id: id.clone(),
198 digest: installed.digest.clone(),
199 root: package,
200 hooks_trusted: installed.hooks.is_empty()
201 || installed.trusted_hook_digest.as_deref() == Some(&installed.digest),
202 }),
203 }
204 }
205 Ok(resolved)
206 }
207
208 pub(crate) fn verify_installed_snapshots(&self, config: &GatewayConfig) -> Result<()> {
209 for (id, installed) in &config.installed_extensions {
210 let package = self.snapshot_root(&installed.digest);
211 verify_snapshot(&package, &installed.digest)?;
212 verify_installed_metadata(id, installed, &package)?;
213 }
214 Ok(())
215 }
216
217 pub(crate) fn remove_snapshot(&self, digest: &str) -> Result<()> {
218 if !valid_digest(digest) {
219 return Err(Error::Config("extension snapshot digest is invalid".into()));
220 }
221 let snapshots = self.root.join("snapshots");
222 let directory = self.snapshot_directory(digest);
223 for path in [&self.root, &snapshots, &directory] {
224 match fs::symlink_metadata(path) {
225 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
226 return Err(Error::Config(format!(
227 "extension store path is not a regular directory: {}",
228 path.display()
229 )));
230 }
231 Ok(_) => {}
232 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
233 Err(error) => return Err(error.into()),
234 }
235 }
236 thaw_tree(&directory)?;
237 fs::remove_dir_all(directory)?;
238 Ok(())
239 }
240
241 pub(crate) fn prune(&self, config: &GatewayConfig) -> Result<()> {
242 let snapshots = self.root.join("snapshots");
243 let metadata = match fs::symlink_metadata(&snapshots) {
244 Ok(metadata) => metadata,
245 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
246 Err(error) => return Err(error.into()),
247 };
248 if metadata.file_type().is_symlink() || !metadata.is_dir() {
249 return Err(Error::Config(
250 "extension snapshot store is not a regular directory".into(),
251 ));
252 }
253 let retained = config
254 .installed_extensions
255 .values()
256 .map(|extension| extension.digest.as_str())
257 .collect::<BTreeSet<_>>();
258 for entry in fs::read_dir(snapshots)? {
259 let entry = entry?;
260 let Some(digest) = entry.file_name().to_str().map(str::to_owned) else {
261 continue;
262 };
263 if valid_digest(&digest) && !retained.contains(digest.as_str()) {
264 self.remove_snapshot(&digest)?;
265 }
266 }
267 Ok(())
268 }
269
270 fn snapshot_root(&self, digest: &str) -> PathBuf {
271 self.snapshot_directory(digest).join("package")
272 }
273
274 fn snapshot_directory(&self, digest: &str) -> PathBuf {
275 self.root.join("snapshots").join(digest)
276 }
277
278 #[cfg(test)]
279 pub(crate) fn commit_test_snapshot(&self, package: &Path) -> Result<String> {
280 prepare_private_directory(&self.root)?;
281 let digest = tree_digest(package)?;
282 let snapshot = self.snapshot_root(&digest);
283 let parent = snapshot
284 .parent()
285 .ok_or_else(|| Error::Config("extension snapshot has no parent directory".into()))?;
286 fs::create_dir_all(parent)?;
287 fs::rename(package, &snapshot)?;
288 freeze_tree(parent)?;
289 Ok(digest)
290 }
291}
292
293impl ExtensionSource {
294 fn parse(url: &str, reference: Option<&str>, subdirectory: Option<&str>) -> Result<Self> {
295 let mut url = Url::parse(url.trim())
296 .map_err(|error| Error::Config(format!("invalid extension URL: {error}")))?;
297 let mut reference = reference
298 .map(str::trim)
299 .filter(|value| !value.is_empty())
300 .map(str::to_owned);
301 let mut subdirectory = subdirectory
302 .map(str::trim)
303 .filter(|value| !value.is_empty())
304 .map(str::to_owned);
305 if url.host_str() == Some("github.com") {
306 let segments = url
307 .path_segments()
308 .map(|segments| segments.map(str::to_owned).collect::<Vec<_>>())
309 .unwrap_or_default();
310 if segments.len() >= 4 && segments[2] == "tree" {
311 if reference.is_some() || subdirectory.is_some() {
312 return Err(Error::Config(
313 "a GitHub tree URL cannot be combined with ref or subdirectory fields"
314 .into(),
315 ));
316 }
317 reference = Some(segments[3].clone());
318 let path = format!("/{}/{}", segments[0], segments[1]);
319 url.set_path(&path);
320 if segments.len() > 4 {
321 subdirectory = Some(segments[4..].join("/"));
322 }
323 }
324 }
325 let source = Self {
326 url: url.to_string().trim_end_matches('/').to_owned(),
327 reference,
328 subdirectory,
329 };
330 source.validate()?;
331 Ok(source)
332 }
333
334 fn validate(&self) -> Result<()> {
335 if self.url.len() > MAX_SOURCE_BYTES || self.url.trim() != self.url {
336 return Err(Error::Config("extension URL is invalid".into()));
337 }
338 let parsed = Url::parse(&self.url)
339 .map_err(|error| Error::Config(format!("invalid extension URL: {error}")))?;
340 if parsed.scheme() != "https"
341 || parsed.host_str().is_none()
342 || !parsed.username().is_empty()
343 || parsed.password().is_some()
344 || parsed.port().is_some()
345 || parsed.query().is_some()
346 || parsed.fragment().is_some()
347 {
348 return Err(Error::Config(
349 "extension source must be a credential-free HTTPS Git URL".into(),
350 ));
351 }
352 if self.reference.as_ref().is_some_and(|reference| {
353 reference.is_empty()
354 || reference.len() > MAX_REFERENCE_BYTES
355 || reference.starts_with('-')
356 || reference.chars().any(char::is_whitespace)
357 }) {
358 return Err(Error::Config("extension Git ref is invalid".into()));
359 }
360 if let Some(path) = self.subdirectory.as_deref() {
361 validate_relative_path(path)?;
362 }
363 Ok(())
364 }
365}
366
367pub(crate) fn records(config: &GatewayConfig) -> Vec<ExtensionRecord> {
368 config
369 .installed_extensions
370 .iter()
371 .map(|(id, installed)| ExtensionRecord {
372 id: id.clone(),
373 capability: MANIFEST.id.into(),
374 kind: installed.kind,
375 name: installed.name.clone(),
376 description: installed.description.clone(),
377 version: installed.version.clone(),
378 source: installed.source.url.clone(),
379 reference: installed.source.reference.clone(),
380 subdirectory: installed.source.subdirectory.clone(),
381 resolved_revision: installed.resolved_revision.clone(),
382 digest: installed.digest.clone(),
383 skills: installed.skills.clone(),
384 hooks: installed.hooks.clone(),
385 hooks_trusted: installed.hooks.is_empty()
386 || installed.trusted_hook_digest.as_deref() == Some(&installed.digest),
387 })
388 .collect()
389}
390
391pub(crate) fn validate_ids(ids: &BTreeSet<String>) -> Result<()> {
392 if ids.len() > MAX_EXTENSIONS {
393 return Err(Error::Config(format!(
394 "an agent may activate at most {MAX_EXTENSIONS} extensions"
395 )));
396 }
397 for id in ids {
398 let Some((kind, name)) = id.split_once(':') else {
399 return Err(Error::Config(format!("invalid extension ID `{id}`")));
400 };
401 if !matches!(kind, "skill" | "plugin") || !valid_package_name(name) {
402 return Err(Error::Config(format!("invalid extension ID `{id}`")));
403 }
404 }
405 Ok(())
406}
407
408pub(crate) fn validate_installed(installed: &BTreeMap<String, InstalledExtension>) -> Result<()> {
409 if installed.len() > MAX_EXTENSIONS {
410 return Err(Error::Config(format!(
411 "installed extension count exceeds {MAX_EXTENSIONS}"
412 )));
413 }
414 let mut digests = BTreeSet::new();
415 for (id, extension) in installed {
416 extension.source.validate()?;
417 if id != &extension_id(extension.kind, &extension.name)
418 || !valid_package_name(&extension.name)
419 {
420 return Err(Error::Config(format!(
421 "invalid installed extension ID `{id}`"
422 )));
423 }
424 if !valid_digest(&extension.digest)
425 || !valid_revision(&extension.resolved_revision)
426 || extension
427 .trusted_hook_digest
428 .as_ref()
429 .is_some_and(|digest| digest != &extension.digest)
430 {
431 return Err(Error::Config(format!(
432 "extension `{id}` has invalid snapshot metadata"
433 )));
434 }
435 if !digests.insert(&extension.digest) {
436 return Err(Error::Config(format!(
437 "extension `{id}` reuses another extension snapshot"
438 )));
439 }
440 if extension.description.len() > 4_096
441 || extension
442 .version
443 .as_ref()
444 .is_some_and(|value| value.len() > 128)
445 || extension.skills.len() > 64
446 || extension.hooks.len() > 64
447 {
448 return Err(Error::Config(format!(
449 "extension `{id}` metadata is too large"
450 )));
451 }
452 }
453 Ok(())
454}
455
456fn extension_id(kind: ExtensionKind, name: &str) -> String {
457 let kind = match kind {
458 ExtensionKind::Skill => "skill",
459 ExtensionKind::Plugin => "plugin",
460 };
461 format!("{kind}:{name}")
462}
463
464impl From<ExtensionPackageKind> for ExtensionKind {
465 fn from(kind: ExtensionPackageKind) -> Self {
466 match kind {
467 ExtensionPackageKind::Skill => Self::Skill,
468 ExtensionPackageKind::Plugin => Self::Plugin,
469 }
470 }
471}
472
473impl From<ExtensionHook> for ExtensionHookRecord {
474 fn from(hook: ExtensionHook) -> Self {
475 Self {
476 event: hook.event,
477 matcher: hook.matcher,
478 command: hook.command,
479 timeout_seconds: hook.timeout_seconds,
480 }
481 }
482}
483
484fn valid_digest(value: &str) -> bool {
485 value.len() == 64
486 && value
487 .bytes()
488 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
489}
490
491fn valid_revision(value: &str) -> bool {
492 matches!(value.len(), 40 | 64)
493 && value
494 .bytes()
495 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
496}
497
498async fn clone_source(source: &ExtensionSource, checkout: &Path) -> Result<()> {
499 let mut command = git_command();
501 command.args(["clone", "--quiet", "--depth", "1", "--no-tags"]);
502 if let Some(reference) = &source.reference {
503 command.arg("--branch").arg(reference);
504 }
505 command.arg("--").arg(&source.url).arg(checkout);
506 command.stdout(Stdio::null()).stderr(Stdio::null());
507 let status = tokio::time::timeout(GIT_TIMEOUT, command.status())
508 .await
509 .map_err(|_| Error::Config("extension Git clone timed out".into()))??;
510 if !status.success() {
511 return Err(Error::Config("extension Git clone failed".into()));
512 }
513 Ok(())
514}
515
516async fn git_revision(checkout: &Path) -> Result<String> {
517 let mut command = git_command();
518 command
519 .current_dir(checkout)
520 .args(["rev-parse", "--verify", "HEAD"])
521 .stderr(Stdio::null());
522 let output = tokio::time::timeout(GIT_TIMEOUT, command.output())
523 .await
524 .map_err(|_| Error::Config("extension Git revision lookup timed out".into()))??;
525 let revision = String::from_utf8(output.stdout)
526 .map_err(|_| Error::Config("extension Git revision is not UTF-8".into()))?;
527 let revision = revision.trim().to_owned();
528 if !output.status.success() || !valid_revision(&revision) {
529 return Err(Error::Config("extension Git revision is invalid".into()));
530 }
531 Ok(revision)
532}
533
534fn git_command() -> Command {
535 let mut command = Command::new("git");
536 command
537 .kill_on_drop(true)
538 .env_clear()
539 .env("GIT_CONFIG_NOSYSTEM", "1")
540 .env("GIT_CONFIG_GLOBAL", "/dev/null")
541 .env("GIT_TERMINAL_PROMPT", "0")
542 .env("GIT_LFS_SKIP_SMUDGE", "1")
543 .env("GIT_OPTIONAL_LOCKS", "0")
544 .arg("-c")
545 .arg("core.hooksPath=/dev/null")
546 .arg("-c")
547 .arg("credential.helper=");
548 for name in [
549 "PATH",
550 "SSL_CERT_FILE",
551 "SSL_CERT_DIR",
552 "HTTPS_PROXY",
553 "HTTP_PROXY",
554 "NO_PROXY",
555 "https_proxy",
556 "http_proxy",
557 "no_proxy",
558 ] {
559 if let Some(value) = std::env::var_os(name) {
560 command.env(name, value);
561 }
562 }
563 command
564}
565
566fn confined_checkout_path(checkout: &Path, subdirectory: Option<&str>) -> Result<PathBuf> {
567 let checkout = fs::canonicalize(checkout)?;
568 let Some(subdirectory) = subdirectory else {
569 return Ok(checkout);
570 };
571 validate_relative_path(subdirectory)?;
572 let mut path = checkout.clone();
573 for component in Path::new(subdirectory).components() {
574 let Component::Normal(component) = component else {
575 return Err(Error::Config("extension subdirectory is invalid".into()));
576 };
577 path.push(component);
578 if fs::symlink_metadata(&path)?.file_type().is_symlink() {
579 return Err(Error::Config(
580 "extension subdirectory contains a symlink".into(),
581 ));
582 }
583 }
584 let path = fs::canonicalize(path)?;
585 if !path.is_dir() || !path.starts_with(&checkout) {
586 return Err(Error::Config(
587 "extension subdirectory escapes its checkout".into(),
588 ));
589 }
590 Ok(path)
591}
592
593fn validate_relative_path(value: &str) -> Result<()> {
594 let path = Path::new(value);
595 if value.is_empty()
596 || value.trim() != value
597 || value.len() > MAX_SUBDIRECTORY_BYTES
598 || path.is_absolute()
599 || path
600 .components()
601 .any(|component| !matches!(component, Component::Normal(_)))
602 {
603 return Err(Error::Config(
604 "extension subdirectory must be a bounded relative path".into(),
605 ));
606 }
607 Ok(())
608}
609
610fn export_package(source: &Path, destination: &Path) -> Result<()> {
611 fs::create_dir(destination)?;
612 let mut files = 0;
613 let mut bytes = 0;
614 copy_directory(source, destination, Path::new(""), &mut files, &mut bytes)
615}
616
617fn copy_directory(
618 source: &Path,
619 destination: &Path,
620 relative: &Path,
621 files: &mut usize,
622 bytes: &mut u64,
623) -> Result<()> {
624 let mut entries = fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
625 entries.sort_by_key(fs::DirEntry::file_name);
626 for entry in entries {
627 if relative.as_os_str().is_empty() && entry.file_name() == ".git" {
628 continue;
629 }
630 let source_path = entry.path();
631 let child = relative.join(entry.file_name());
632 let text = child
633 .to_str()
634 .ok_or_else(|| Error::Config("extension paths must be UTF-8".into()))?;
635 if text.len() > MAX_PATH_BYTES {
636 return Err(Error::Config("extension path is too long".into()));
637 }
638 let metadata = fs::symlink_metadata(&source_path)?;
639 let destination_path = destination.join(entry.file_name());
640 if metadata.is_dir() {
641 fs::create_dir(&destination_path)?;
642 copy_directory(&source_path, &destination_path, &child, files, bytes)?;
643 } else if metadata.is_file() {
644 *files += 1;
645 *bytes = bytes.saturating_add(metadata.len());
646 if *files > MAX_PACKAGE_FILES || *bytes > MAX_PACKAGE_BYTES {
647 return Err(Error::Config("extension package is too large".into()));
648 }
649 fs::copy(&source_path, &destination_path)?;
650 preserve_executable(&metadata, &destination_path)?;
651 } else {
652 return Err(Error::Config(format!(
653 "extension package contains unsupported entry `{text}`"
654 )));
655 }
656 }
657 Ok(())
658}
659
660fn tree_digest(root: &Path) -> Result<String> {
661 let mut hash = Sha256::new();
662 let mut files = 0;
663 let mut bytes = 0;
664 hash_directory(root, root, &mut hash, &mut files, &mut bytes)?;
665 Ok(format!("{:x}", hash.finalize()))
666}
667
668fn hash_directory(
669 root: &Path,
670 directory: &Path,
671 hash: &mut Sha256,
672 files: &mut usize,
673 bytes: &mut u64,
674) -> Result<()> {
675 let mut entries = fs::read_dir(directory)?.collect::<std::io::Result<Vec<_>>>()?;
676 entries.sort_by_key(fs::DirEntry::file_name);
677 for entry in entries {
678 let path = entry.path();
679 let relative = path
680 .strip_prefix(root)
681 .map_err(|_| Error::Config("extension path escaped its snapshot".into()))?;
682 let relative = relative
683 .to_str()
684 .ok_or_else(|| Error::Config("extension paths must be UTF-8".into()))?;
685 if relative.len() > MAX_PATH_BYTES {
686 return Err(Error::Config("extension path is too long".into()));
687 }
688 let metadata = fs::symlink_metadata(&path)?;
689 if metadata.is_dir() {
690 hash.update(b"d");
691 hash.update((relative.len() as u64).to_le_bytes());
692 hash.update(relative.as_bytes());
693 hash_directory(root, &path, hash, files, bytes)?;
694 } else if metadata.is_file() {
695 *files += 1;
696 *bytes = bytes.saturating_add(metadata.len());
697 if *files > MAX_PACKAGE_FILES || *bytes > MAX_PACKAGE_BYTES {
698 return Err(Error::Config("extension package is too large".into()));
699 }
700 hash.update(b"f");
701 hash.update((relative.len() as u64).to_le_bytes());
702 hash.update(relative.as_bytes());
703 hash.update([u8::from(is_executable(&metadata))]);
704 hash.update(metadata.len().to_le_bytes());
705 let mut file = fs::File::open(&path)?;
706 let mut buffer = [0_u8; 16 * 1024];
707 loop {
708 let read = file.read(&mut buffer)?;
709 if read == 0 {
710 break;
711 }
712 hash.update(&buffer[..read]);
713 }
714 } else {
715 return Err(Error::Config(format!(
716 "extension snapshot contains unsupported entry `{relative}`"
717 )));
718 }
719 }
720 Ok(())
721}
722
723fn verify_installed_metadata(
724 id: &str,
725 installed: &InstalledExtension,
726 package: &Path,
727) -> Result<()> {
728 let inspected = inspect_package(package)?;
729 let kind = inspected.kind.into();
730 let hooks = inspected
731 .hooks
732 .into_iter()
733 .map(Into::into)
734 .collect::<Vec<_>>();
735 if installed.kind != kind
736 || installed.name != inspected.name
737 || installed.description != inspected.description
738 || installed.version != inspected.version
739 || installed.skills != inspected.skills
740 || installed.hooks != hooks
741 {
742 return Err(Error::Config(format!(
743 "extension `{id}` metadata does not match its snapshot"
744 )));
745 }
746 Ok(())
747}
748
749fn verify_snapshot(root: &Path, expected: &str) -> Result<()> {
750 let metadata = fs::symlink_metadata(root)
751 .map_err(|error| Error::Config(format!("extension snapshot is unavailable: {error}")))?;
752 if metadata.file_type().is_symlink() || !metadata.is_dir() {
753 return Err(Error::Config(
754 "extension snapshot root is not a regular directory".into(),
755 ));
756 }
757 let actual = tree_digest(root)
758 .map_err(|error| Error::Config(format!("extension snapshot is unavailable: {error}")))?;
759 if actual != expected {
760 return Err(Error::Config("extension snapshot digest changed".into()));
761 }
762 Ok(())
763}
764
765fn prepare_private_directory(path: &Path) -> Result<()> {
766 if path
767 .symlink_metadata()
768 .is_ok_and(|metadata| metadata.file_type().is_symlink())
769 {
770 return Err(Error::Config(
771 "extension store root cannot be a symlink".into(),
772 ));
773 }
774 fs::create_dir_all(path)?;
775 #[cfg(unix)]
776 {
777 use std::os::unix::fs::PermissionsExt as _;
778 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
779 }
780 Ok(())
781}
782
783fn preserve_executable(source: &fs::Metadata, destination: &Path) -> Result<()> {
784 #[cfg(unix)]
785 {
786 use std::os::unix::fs::PermissionsExt as _;
787 let mode = if source.permissions().mode() & 0o111 == 0 {
788 0o600
789 } else {
790 0o700
791 };
792 fs::set_permissions(destination, fs::Permissions::from_mode(mode))?;
793 }
794 Ok(())
795}
796
797fn freeze_tree(path: &Path) -> Result<()> {
798 if path.is_dir() {
799 for entry in fs::read_dir(path)? {
800 freeze_tree(&entry?.path())?;
801 }
802 }
803 set_read_only(path, true)
804}
805
806fn thaw_tree(path: &Path) -> Result<()> {
807 let metadata = fs::symlink_metadata(path)?;
808 if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
809 return Err(Error::Config(
810 "extension snapshot contains an unsupported entry".into(),
811 ));
812 }
813 set_read_only(path, false)?;
814 if metadata.is_dir() {
815 for entry in fs::read_dir(path)? {
816 thaw_tree(&entry?.path())?;
817 }
818 }
819 Ok(())
820}
821
822fn set_read_only(path: &Path, read_only: bool) -> Result<()> {
823 #[cfg(unix)]
824 {
825 use std::os::unix::fs::PermissionsExt as _;
826 let metadata = fs::symlink_metadata(path)?;
827 let executable = metadata.is_dir() || is_executable(&metadata);
828 let mode = match (read_only, executable) {
829 (true, true) => 0o500,
830 (true, false) => 0o400,
831 (false, true) => 0o700,
832 (false, false) => 0o600,
833 };
834 fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
835 }
836 #[cfg(not(unix))]
837 {
838 let mut permissions = fs::metadata(path)?.permissions();
839 permissions.set_readonly(read_only);
840 fs::set_permissions(path, permissions)?;
841 }
842 Ok(())
843}
844
845#[cfg(unix)]
846fn is_executable(metadata: &fs::Metadata) -> bool {
847 use std::os::unix::fs::PermissionsExt as _;
848 metadata.permissions().mode() & 0o111 != 0
849}
850
851#[cfg(not(unix))]
852fn is_executable(_metadata: &fs::Metadata) -> bool {
853 false
854}
855
856pub(crate) fn extensions_path(state_dir: &Path) -> PathBuf {
857 let mut name = state_dir
858 .file_name()
859 .map_or_else(|| OsString::from("mobius"), OsString::from);
860 name.push("-extensions");
861 state_dir.with_file_name(name)
862}
863
864#[cfg(test)]
865#[path = "extensions/tests.rs"]
866mod tests;