Skip to main content

starfield_datastore/
config.rs

1//! Configuration resolution (spec §11): explicit builder call, then
2//! environment, then `~/.config/starfield/datastore.toml`, then defaults.
3
4use crate::{
5    ChainProvider, CredentialProvider, DatastoreBuilder, DatastoreError, EnvProvider, Mirror,
6    NetrcProvider, Result,
7};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::path::PathBuf;
11
12pub(crate) const ENV_CACHE_DIR: &str = "STARFIELD_CACHE_DIR";
13pub(crate) const ENV_MIRROR: &str = "STARFIELD_MIRROR";
14pub(crate) const ENV_MIRROR_REGION: &str = "STARFIELD_MIRROR_REGION";
15pub(crate) const ENV_ALLOW_UPSTREAM: &str = "STARFIELD_ALLOW_UPSTREAM";
16pub(crate) const ENV_OFFLINE: &str = "STARFIELD_OFFLINE";
17pub(crate) const ENV_CACHE_MAX: &str = "STARFIELD_CACHE_MAX";
18pub(crate) const ENV_CONFIG_FILE: &str = "STARFIELD_DATASTORE_CONFIG";
19/// `op://vault/item` reference for the 1Password provider (feature
20/// `onepassword`); each host is a field of that item.
21#[cfg(feature = "onepassword")]
22pub(crate) const ENV_OP_ITEM: &str = "STARFIELD_OP_ITEM";
23
24#[derive(Debug, Default, Clone, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct DatastoreConfig {
27    pub cache_dir: Option<PathBuf>,
28    pub mirror: Option<String>,
29    pub mirror_region: Option<String>,
30    pub allow_upstream: Option<bool>,
31    pub offline: Option<bool>,
32    pub cache_max: Option<u64>,
33    #[serde(default)]
34    pub credentials: Vec<crate::CredentialConfig>,
35    #[serde(default)]
36    pub services: BTreeMap<String, ServiceConfig>,
37}
38
39/// A named instance of the artifact service. Paths are relative to the config
40/// file when loaded with `from_path`; CLI flags override these values.
41#[derive(Debug, Default, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ServiceConfig {
44    /// Optional server-only automatic cache maintenance. Absent means disabled.
45    pub cache: Option<ServiceCachePolicy>,
46    pub manifest: Option<PathBuf>,
47    pub bucket: Option<String>,
48    pub region: Option<String>,
49    pub bind: Option<std::net::SocketAddr>,
50    /// `None` inherits the configured/default chain. An explicit list selects
51    /// only those named credentials, without ambient credential fallback.
52    pub credentials: Option<Vec<String>>,
53}
54
55/// Resource limits for a server-owned cache; ordinary library gets are unchanged.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ServiceCachePolicy {
59    /// Enable maintenance after fills drain. Defaults to false.
60    #[serde(default)]
61    pub evict_on_fetch: bool,
62    /// Quiescent unique-blob budget; zero makes this a staging-only cache.
63    pub max_bytes: u64,
64    /// Maximum number of simultaneous cold fills.
65    pub max_concurrent_fills: usize,
66    /// Maximum bytes materialized by a fill, including its retries.
67    pub max_artifact_bytes: u64,
68    /// Aggregate reservations, retained until maintenance finishes.
69    pub max_inflight_bytes: u64,
70    /// Required unreserved filesystem free space at admission.
71    pub min_free_bytes: u64,
72}
73
74impl ServiceCachePolicy {
75    /// Reject limits that cannot admit a maximum-sized artifact safely.
76    pub fn validate(&self) -> Result<()> {
77        if self.max_concurrent_fills == 0
78            || self.max_artifact_bytes == 0
79            || self.max_inflight_bytes < self.max_artifact_bytes
80            || self
81                .max_inflight_bytes
82                .checked_add(self.min_free_bytes)
83                .is_none()
84            || self
85                .max_inflight_bytes
86                .checked_add(self.max_bytes)
87                .is_none()
88        {
89            return Err(DatastoreError::Config(
90                "invalid service cache limits".into(),
91            ));
92        }
93        Ok(())
94    }
95}
96
97impl DatastoreConfig {
98    pub fn from_env() -> Result<Self> {
99        read_file()
100    }
101
102    pub fn from_path(path: &std::path::Path) -> Result<Self> {
103        let text = std::fs::read_to_string(path).map_err(|error| {
104            DatastoreError::Config(format!(
105                "cannot read configuration {}: {error}; supply --config or mount the file at this path",
106                path.display()
107            ))
108        })?;
109        let mut config = Self::from_toml_str(&text)?;
110        let base = std::fs::canonicalize(path)?;
111        let parent = base.parent().expect("a config file has a parent");
112        if let Some(root) = &mut config.cache_dir {
113            if root.is_relative() {
114                *root = parent.join(&*root);
115            }
116        }
117        for service in config.services.values_mut() {
118            if let Some(manifest) = &mut service.manifest {
119                if manifest.is_relative() {
120                    *manifest = parent.join(&*manifest);
121                }
122            }
123        }
124        Ok(config)
125    }
126
127    pub fn from_toml_str(text: &str) -> Result<Self> {
128        let config: Self = toml::from_str(text).map_err(|error: toml::de::Error| {
129            let line = error.span().map(|span| {
130                text[..span.start.min(text.len())]
131                    .bytes()
132                    .filter(|b| *b == b'\n')
133                    .count()
134                    + 1
135            });
136            DatastoreError::Config(format!(
137                "invalid configuration{}; check field names and credential types",
138                line.map(|n| format!(" at line {n}")).unwrap_or_default()
139            ))
140        })?;
141        config.validate()?;
142        Ok(config)
143    }
144
145    pub fn to_toml_string(&self) -> Result<String> {
146        toml::to_string_pretty(self).map_err(|_| {
147            DatastoreError::Config("configuration cannot be represented as TOML".into())
148        })
149    }
150
151    /// Apply environment overrides, then this file. Credentials remain lazy.
152    pub fn builder(&self) -> Result<DatastoreBuilder> {
153        self.validate()?;
154        apply_file(DatastoreBuilder::default(), self.clone())
155    }
156
157    pub fn builder_for_service(&self, name: &str) -> Result<DatastoreBuilder> {
158        let service = self
159            .services
160            .get(name)
161            .ok_or_else(|| DatastoreError::Config(format!("unknown service {name}")))?;
162        self.validate()?;
163        let mut builder = DatastoreBuilder::default();
164        if let Some(names) = &service.credentials {
165            let entries = names
166                .iter()
167                .map(|name| {
168                    self.credentials
169                        .iter()
170                        .find(|entry| &entry.name == name)
171                        .cloned()
172                        .ok_or_else(|| DatastoreError::Config(format!("unknown credential {name}")))
173                })
174                .collect::<Result<Vec<_>>>()?;
175            builder.credentials = Some(provider_with_warnings(entries)?);
176        }
177        apply_file(builder, self.clone())
178    }
179
180    fn validate(&self) -> Result<()> {
181        let mut names = std::collections::HashSet::new();
182        for entry in &self.credentials {
183            if !names.insert(&entry.name) {
184                return Err(DatastoreError::Config(format!(
185                    "duplicate credential {}",
186                    entry.name
187                )));
188            }
189            crate::credential_config::validate_entries(std::slice::from_ref(entry))?;
190        }
191        for (service_name, service) in &self.services {
192            if let Some(policy) = &service.cache {
193                policy.validate()?;
194                if policy.evict_on_fetch && self.cache_dir.is_none() {
195                    return Err(DatastoreError::Config(
196                        "automatic service eviction requires an explicit cache_dir".into(),
197                    ));
198                }
199            }
200            if service_name.trim().is_empty() {
201                return Err(DatastoreError::Config("service name is empty".into()));
202            }
203            if let Some(selected) = &service.credentials {
204                for name in selected {
205                    if !names.contains(name) {
206                        return Err(DatastoreError::Config(format!(
207                            "service {service_name} names unknown credential {name}"
208                        )));
209                    }
210                }
211            }
212        }
213        Ok(())
214    }
215}
216
217/// Fill every builder field the caller left unset from the environment,
218/// then the config file. Explicit builder calls always win.
219pub(crate) fn apply(builder: DatastoreBuilder) -> Result<DatastoreBuilder> {
220    apply_file(builder, read_file()?)
221}
222
223fn apply_file(mut builder: DatastoreBuilder, file: DatastoreConfig) -> Result<DatastoreBuilder> {
224    if builder.cache_root.is_none() {
225        builder.cache_root = env_var(ENV_CACHE_DIR).map(PathBuf::from).or(file.cache_dir);
226    }
227    if builder.mirror.is_none() {
228        let region = env_var(ENV_MIRROR_REGION).or(file.mirror_region);
229        builder.mirror = match env_var(ENV_MIRROR).or(file.mirror) {
230            Some(spec) => Some(parse_mirror(&spec, region)?),
231            None => None,
232        };
233    }
234    if builder.allow_upstream.is_none() {
235        builder.allow_upstream = env_bool(ENV_ALLOW_UPSTREAM)?.or(file.allow_upstream);
236    }
237    if builder.offline.is_none() {
238        builder.offline = env_bool(ENV_OFFLINE)?.or(file.offline);
239    }
240    if builder.credentials.is_none() {
241        builder.credentials = Some(Box::new(ChainProvider(vec![
242            provider_with_warnings(file.credentials)?,
243            default_credentials(),
244        ])));
245    }
246    if builder.max_bytes.is_none() {
247        builder.max_bytes = match env_var(ENV_CACHE_MAX) {
248            Some(raw) => Some(raw.parse().map_err(|_| {
249                DatastoreError::Config(format!("{ENV_CACHE_MAX} must be a byte count, got {raw:?}"))
250            })?),
251            None => file.cache_max,
252        };
253    }
254    Ok(builder)
255}
256
257fn provider_with_warnings(
258    entries: Vec<crate::CredentialConfig>,
259) -> Result<Box<dyn CredentialProvider>> {
260    let (provider, skipped) = crate::credential_config::configured_provider(entries)?;
261    for warning in skipped {
262        eprintln!("warning: {warning}");
263    }
264    Ok(provider)
265}
266
267/// What a client off the tailnet fetches upstream with (spec §2.4): its own
268/// `STARFIELD_TOKEN_<HOST>` variables and `~/.netrc`, plus 1Password when
269/// built in and `STARFIELD_OP_ITEM` names an item. Every provider looks its
270/// credential up lazily, per request, so nothing is read until the upstream
271/// layer is actually used.
272fn default_credentials() -> Box<dyn CredentialProvider> {
273    let chain: Vec<Box<dyn CredentialProvider>> =
274        vec![Box::new(EnvProvider), Box::new(NetrcProvider)];
275    #[cfg(feature = "onepassword")]
276    let chain = match env_var(ENV_OP_ITEM) {
277        Some(item) => {
278            let mut chain = chain;
279            chain.push(Box::new(crate::OnePasswordProvider { item }));
280            chain
281        }
282        None => chain,
283    };
284    Box::new(ChainProvider(chain))
285}
286
287fn read_file() -> Result<DatastoreConfig> {
288    let Some(path) = config_path() else {
289        return Ok(DatastoreConfig::default());
290    };
291    if !path.exists() {
292        return Ok(DatastoreConfig::default());
293    }
294    DatastoreConfig::from_path(&path)
295}
296
297fn config_path() -> Option<PathBuf> {
298    if let Some(explicit) = env_var(ENV_CONFIG_FILE) {
299        return Some(PathBuf::from(explicit));
300    }
301    let base = env_var("XDG_CONFIG_HOME")
302        .map(PathBuf::from)
303        .or_else(|| home().map(|h| h.join(".config")))?;
304    Some(base.join("starfield").join("datastore.toml"))
305}
306
307/// `~/.cache/starfield`, shared with the pre-datastore downloader so nothing
308/// is re-downloaded on the day of the switch.
309pub(crate) fn default_cache_root() -> Result<PathBuf> {
310    let base = env_var("XDG_CACHE_HOME")
311        .map(PathBuf::from)
312        .or_else(|| home().map(|h| h.join(".cache")))
313        .ok_or_else(|| {
314            DatastoreError::Config(format!(
315                "cannot locate a cache directory: set {ENV_CACHE_DIR} or HOME"
316            ))
317        })?;
318    Ok(base.join("starfield"))
319}
320
321fn home() -> Option<PathBuf> {
322    env_var("HOME").map(PathBuf::from)
323}
324
325fn env_var(name: &str) -> Option<String> {
326    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
327}
328
329fn env_bool(name: &str) -> Result<Option<bool>> {
330    env_var(name).map(|raw| parse_bool(name, &raw)).transpose()
331}
332
333fn parse_bool(name: &str, raw: &str) -> Result<bool> {
334    match raw.trim().to_ascii_lowercase().as_str() {
335        "1" | "true" | "yes" | "on" => Ok(true),
336        "0" | "false" | "no" | "off" => Ok(false),
337        other => Err(DatastoreError::Config(format!(
338            "{name} must be 1/0, true/false, yes/no or on/off, got {other:?}"
339        ))),
340    }
341}
342
343/// `https://…` is an HTTP mirror; `s3://bucket/prefix` is an S3 mirror and
344/// needs a region from the argument, `AWS_REGION` or `AWS_DEFAULT_REGION`.
345pub(crate) fn parse_mirror(spec: &str, region: Option<String>) -> Result<Mirror> {
346    if let Some(rest) = spec.strip_prefix("s3://") {
347        let (bucket, prefix) = rest.split_once('/').unwrap_or((rest, ""));
348        if bucket.is_empty() {
349            return Err(DatastoreError::Config(format!(
350                "mirror {spec:?} has no bucket"
351            )));
352        }
353        let region = region
354            .or_else(|| env_var("AWS_REGION"))
355            .or_else(|| env_var("AWS_DEFAULT_REGION"))
356            .ok_or_else(|| {
357                DatastoreError::Config(format!(
358                    "S3 mirror {spec} needs a region: set {ENV_MIRROR_REGION} or AWS_REGION"
359                ))
360            })?;
361        return Ok(Mirror::S3 {
362            bucket: bucket.to_string(),
363            prefix: prefix.trim_matches('/').to_string(),
364            region,
365            writable: false,
366        });
367    }
368    if spec.starts_with("http://") || spec.starts_with("https://") {
369        return Ok(Mirror::Http {
370            base_url: spec.trim_end_matches('/').to_string(),
371            writable: false,
372        });
373    }
374    Err(DatastoreError::Config(format!(
375        "mirror {spec:?} must be an http(s):// URL or s3://bucket/prefix"
376    )))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn managed_cache_is_explicit_and_rejects_unusable_limits() {
385        let raw = "cache_dir='/server/cache'\n[services.ephemeris.cache]\nevict_on_fetch=true\nmax_bytes=0\nmax_concurrent_fills=2\nmax_artifact_bytes=16\nmax_inflight_bytes=32\nmin_free_bytes=20\n";
386        let config = DatastoreConfig::from_toml_str(raw).unwrap();
387        let policy = config.services["ephemeris"].cache.as_ref().unwrap();
388        assert!(policy.evict_on_fetch);
389        assert_eq!(policy.max_bytes, 0);
390        assert!(
391            DatastoreConfig::from_toml_str(&raw.replace("cache_dir='/server/cache'\n", ""))
392                .is_err()
393        );
394        assert!(DatastoreConfig::from_toml_str(
395            &raw.replace("max_concurrent_fills=2", "max_concurrent_fills=0")
396        )
397        .is_err());
398        assert!(DatastoreConfig::from_toml_str(
399            &raw.replace("max_artifact_bytes=16", "max_artifact_bytes=0")
400        )
401        .is_err());
402        assert!(DatastoreConfig::from_toml_str(
403            &raw.replace("max_inflight_bytes=32", "max_inflight_bytes=8")
404        )
405        .is_err());
406        let disabled =
407            DatastoreConfig::from_toml_str(&raw.replace("evict_on_fetch=true\n", "")).unwrap();
408        assert!(
409            !disabled.services["ephemeris"]
410                .cache
411                .as_ref()
412                .unwrap()
413                .evict_on_fetch
414        );
415        let ordinary = DatastoreConfig::from_toml_str(
416            "cache_max=0\n[services.ephemeris]\nbind='127.0.0.1:8080'",
417        )
418        .unwrap();
419        assert!(ordinary.services["ephemeris"].cache.is_none());
420    }
421
422    #[test]
423    fn booleans_are_lenient_but_explicit() {
424        for raw in ["1", "true", "YES", "on"] {
425            assert!(parse_bool("X", raw).unwrap());
426        }
427        for raw in ["0", "false", "No", "off"] {
428            assert!(!parse_bool("X", raw).unwrap());
429        }
430        assert!(parse_bool("X", "maybe").is_err());
431    }
432
433    #[test]
434    fn mirror_specs_parse() {
435        assert_eq!(
436            parse_mirror("https://ephem.example/", None).unwrap(),
437            Mirror::Http {
438                base_url: "https://ephem.example".into(),
439                writable: false
440            }
441        );
442        assert_eq!(
443            parse_mirror("s3://bucket/some/prefix/", Some("us-west-2".into())).unwrap(),
444            Mirror::S3 {
445                bucket: "bucket".into(),
446                prefix: "some/prefix".into(),
447                region: "us-west-2".into(),
448                writable: false
449            }
450        );
451        assert!(parse_mirror("ftp://x", None).is_err());
452        assert!(parse_mirror("s3:///prefix", None).is_err());
453    }
454
455    #[test]
456    fn file_config_rejects_unknown_keys() {
457        let parsed: std::result::Result<DatastoreConfig, _> =
458            toml::from_str("cache_dir = \"/x\"\nmirror = \"https://m\"\n");
459        assert!(parsed.is_ok());
460        let bad: std::result::Result<DatastoreConfig, _> = toml::from_str("cache_dirr = \"/x\"\n");
461        assert!(bad.is_err());
462    }
463
464    #[test]
465    fn profiles_select_credentials_before_resolving_secrets() {
466        let config = DatastoreConfig::from_toml_str(
467            r#"
468[[credentials]]
469name = "first"
470hosts = ["archive.example.org"]
471type = "env"
472variable = "SFD_CONFIG_UNSET_FIRST"
473[[credentials]]
474name = "second"
475hosts = ["archive.example.org"]
476type = "env"
477variable = "SFD_CONFIG_UNSET_SECOND"
478[services.first]
479credentials = ["first"]
480[services.second]
481credentials = ["second"]
482"#,
483        )
484        .unwrap();
485        assert!(config.builder_for_service("first").is_ok());
486        assert!(config.builder_for_service("second").is_ok());
487        assert!(
488            config.builder().is_err(),
489            "an ambiguous active host selection fails"
490        );
491        assert!(config.builder_for_service("missing").is_err());
492        assert!(DatastoreConfig::from_toml_str("[services.bad]\ncredentials=['missing']").is_err());
493    }
494
495    #[test]
496    fn configuration_round_trips_unknown_payloads_without_debug_leaks() {
497        let config = DatastoreConfig::from_toml_str(
498            r#"
499[[credentials]]
500name = "future"
501hosts = ["future.example.org"]
502type = "future-auth"
503options = { opaque = "sensitive-payload", flags = [true, false], count = 4 }
504[services.preview]
505credentials = ["future"]
506"#,
507        )
508        .unwrap();
509        let encoded = config.to_toml_string().unwrap();
510        let decoded = DatastoreConfig::from_toml_str(&encoded).unwrap();
511        assert_eq!(config.credentials, decoded.credentials);
512        assert!(!format!("{config:?}").contains("sensitive-payload"));
513        assert!(decoded.builder_for_service("preview").is_ok());
514        let error = DatastoreConfig::from_toml_str(
515            "[[credentials]]\nname='bad'\nhosts=['h']\ntype='basic'\npassword='secret-value'",
516        )
517        .unwrap_err();
518        assert!(!error.to_string().contains("secret-value"));
519    }
520
521    #[test]
522    fn config_relative_paths_follow_the_file_location() {
523        let dir = tempfile::tempdir().unwrap();
524        let path = dir.path().join("config.toml");
525        std::fs::write(
526            &path,
527            "cache_dir='cache'\n[services.one]\nmanifest='kernels.toml'",
528        )
529        .unwrap();
530        let config = DatastoreConfig::from_path(&path).unwrap();
531        assert_eq!(config.cache_dir.unwrap(), dir.path().join("cache"));
532        assert_eq!(
533            config.services["one"].manifest.as_ref().unwrap(),
534            &dir.path().join("kernels.toml")
535        );
536    }
537}