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}