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;
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    // ponytail: clone is time-bounded; use a quota-aware fetcher before accepting untrusted catalogs.
500    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;