1use 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#[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ServiceConfig {
44 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 pub credentials: Option<Vec<String>>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ServiceCachePolicy {
59 #[serde(default)]
61 pub evict_on_fetch: bool,
62 pub max_bytes: u64,
64 pub max_concurrent_fills: usize,
66 pub max_artifact_bytes: u64,
68 pub max_inflight_bytes: u64,
70 pub min_free_bytes: u64,
72}
73
74impl ServiceCachePolicy {
75 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 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
217pub(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
267fn 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
307pub(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
343pub(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}