Skip to main content

pg_embedded_setup_unpriv/extensions/
config.rs

1//! Translates `PG_EXTENSIONS*` into an [`ExtensionRequest`] and resolves the
2//! extension cache directory.
3//!
4//! The cache directory travels as `ExtensionRequest.cache_dir`; there is no
5//! separate configuration type, because a wrapper around one field earns
6//! nothing until a second field exists.
7
8use std::path::PathBuf;
9
10use camino::Utf8PathBuf;
11use color_eyre::eyre::eyre;
12
13use super::{ExtensionName, ExtensionRequest, ManifestSource, Sha256Hex, extension_error};
14use crate::{
15    PgEnvCfg,
16    error::{BootstrapErrorKind, BootstrapResult},
17};
18
19/// Subdirectory path within the XDG cache home.
20const CACHE_SUBDIR: &str = "pg-embedded/extensions";
21
22/// Resolves the extension cache directory.
23///
24/// The resolution order mirrors the binary cache:
25///
26/// 1. `PG_EXTENSIONS_CACHE_DIR` if set, non-empty and valid UTF-8
27/// 2. `$XDG_CACHE_HOME/pg-embedded/extensions` if `XDG_CACHE_HOME` is set
28/// 3. `~/.cache/pg-embedded/extensions`
29/// 4. `std::env::temp_dir()/pg-embedded/extensions` as a last resort
30///
31/// # Examples
32///
33/// ```
34/// use pg_embedded_setup_unpriv::extensions::resolve_extension_cache_dir;
35///
36/// assert!(!resolve_extension_cache_dir().as_str().is_empty());
37/// ```
38#[must_use]
39pub fn resolve_extension_cache_dir() -> Utf8PathBuf {
40    non_empty_env("PG_EXTENSIONS_CACHE_DIR")
41        .map(Utf8PathBuf::from)
42        .or_else(|| {
43            non_empty_env("XDG_CACHE_HOME").map(|home| Utf8PathBuf::from(home).join(CACHE_SUBDIR))
44        })
45        .or_else(home_cache_dir)
46        .unwrap_or_else(temp_cache_dir)
47}
48
49/// Reads an environment variable, treating blank values as unset.
50fn non_empty_env(key: &str) -> Option<String> {
51    std::env::var(key)
52        .ok()
53        .map(|raw| raw.trim().to_owned())
54        .filter(|value| !value.is_empty())
55}
56
57/// `~/.cache/pg-embedded/extensions` when the home directory is known.
58fn home_cache_dir() -> Option<Utf8PathBuf> {
59    let home = Utf8PathBuf::from_path_buf(dirs::home_dir()?).ok()?;
60    Some(home.join(".cache").join(CACHE_SUBDIR))
61}
62
63/// The last-resort cache directory under the platform temporary directory.
64fn temp_cache_dir() -> Utf8PathBuf {
65    let temp: PathBuf = std::env::temp_dir().join("pg-embedded").join("extensions");
66    Utf8PathBuf::from_path_buf(temp)
67        .unwrap_or_else(|path| Utf8PathBuf::from(path.to_string_lossy().into_owned()))
68}
69
70impl ExtensionRequest {
71    /// Builds a request from the `PG_EXTENSIONS*` variables captured in `cfg`.
72    ///
73    /// Returns `Ok(None)` when `PG_EXTENSIONS` is unset, empty or whitespace,
74    /// so the hook stays inert for consumers that do not declare extensions.
75    ///
76    /// # Errors
77    ///
78    /// Returns `ExtensionConfigInvalid` when names are declared without a
79    /// manifest, when a URL manifest has no digest, or when a name or digest
80    /// is malformed. Every URL manifest needs one, loopback `http://`
81    /// included: the digest is what makes the fetched bytes checkable, and
82    /// loopback does not supply that.
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// use pg_embedded_setup_unpriv::{PgEnvCfg, extensions::ExtensionRequest};
88    ///
89    /// let cfg = PgEnvCfg {
90    ///     extensions: Some("vector, vector".into()),
91    ///     extensions_manifest: Some("/srv/extensions/manifest.json".into()),
92    ///     ..PgEnvCfg::default()
93    /// };
94    /// let request = ExtensionRequest::from_config(&cfg)?.expect("names were declared");
95    /// assert_eq!(request.names.len(), 1, "duplicates collapse in order");
96    /// # Ok::<(), pg_embedded_setup_unpriv::BootstrapError>(())
97    /// ```
98    pub fn from_config(cfg: &PgEnvCfg) -> BootstrapResult<Option<Self>> {
99        let names = parse_names(cfg.extensions.as_deref().unwrap_or_default())?;
100        if names.is_empty() {
101            return Ok(None);
102        }
103        let manifest = parse_manifest_source(
104            cfg.extensions_manifest.as_deref(),
105            cfg.extensions_manifest_sha256.as_deref(),
106        )?;
107        let cache_dir = cfg
108            .extensions_cache_dir
109            .clone()
110            .unwrap_or_else(resolve_extension_cache_dir);
111        Ok(Some(Self {
112            names,
113            manifest,
114            cache_dir,
115        }))
116    }
117}
118
119/// Splits `PG_EXTENSIONS` on commas, trims, validates and deduplicates in order.
120fn parse_names(raw: &str) -> BootstrapResult<Vec<ExtensionName>> {
121    let mut names: Vec<ExtensionName> = Vec::new();
122    for item in raw
123        .split(',')
124        .map(str::trim)
125        .filter(|item| !item.is_empty())
126    {
127        let name = ExtensionName::new(item)?;
128        if !names.contains(&name) {
129            names.push(name);
130        }
131    }
132    Ok(names)
133}
134
135/// Classifies the manifest location as a URL (digest required) or a path.
136///
137/// A location is a URL when [`is_permitted_url`](super::is_permitted_url)
138/// accepts it, which is the same rule the archive fetches apply: `https://`
139/// anywhere, and `http://` only to a loopback host. Sharing the rule keeps a
140/// local mirror from serving the archives a manifest names while the manifest
141/// itself is refused, and it parses the location, so `"https://"` and
142/// `"https://[bad"` fail here as configuration rather than later as an
143/// unavailable manifest. The digest stays mandatory for every URL source,
144/// loopback included: it is what pins the archives the manifest describes.
145fn parse_manifest_source(
146    raw_location: Option<&str>,
147    digest: Option<&str>,
148) -> BootstrapResult<ManifestSource> {
149    let Some(location) = raw_location
150        .map(str::trim)
151        .filter(|value| !value.is_empty())
152    else {
153        return Err(config_error(
154            "PG_EXTENSIONS is set but PG_EXTENSIONS_MANIFEST is not; the manifest pins which \
155             archives may be installed",
156        ));
157    };
158    let pinned = digest
159        .map(str::trim)
160        .filter(|value| !value.is_empty())
161        .map(parse_digest)
162        .transpose()?;
163    if super::is_permitted_url(location) {
164        let Some(sha256) = pinned else {
165            return Err(config_error(
166                "PG_EXTENSIONS_MANIFEST is a URL, so PG_EXTENSIONS_MANIFEST_SHA256 is required to \
167                 pin it",
168            ));
169        };
170        return Ok(ManifestSource::Url {
171            url: location.to_owned(),
172            sha256,
173        });
174    }
175    if location.contains("://") {
176        return Err(config_error(
177            "PG_EXTENSIONS_MANIFEST must be an https:// URL, a loopback http:// URL, or a \
178             filesystem path",
179        ));
180    }
181    Ok(ManifestSource::Path {
182        path: Utf8PathBuf::from(location),
183        sha256: pinned,
184    })
185}
186
187/// Parses `PG_EXTENSIONS_MANIFEST_SHA256`, mapping a bad digest to a config error.
188fn parse_digest(value: &str) -> BootstrapResult<Sha256Hex> {
189    Sha256Hex::parse(value)
190        .map_err(|err| config_error(&format!("PG_EXTENSIONS_MANIFEST_SHA256: {err}")))
191}
192
193/// Builds an `ExtensionConfigInvalid` error from a message.
194fn config_error(message: &str) -> crate::error::BootstrapError {
195    extension_error(
196        BootstrapErrorKind::ExtensionConfigInvalid,
197        eyre!("{message}"),
198    )
199}