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