Skip to main content

mobius_gateway/
extensions.rs

1//! Owner-managed extension sources and immutable package snapshots.
2
3use 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;
26/// Maximum UTF-8 byte length of an extension source URL.
27pub 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    // ponytail: clone is time-bounded; use a quota-aware fetcher before accepting untrusted catalogs.
510    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;