1use std::path::PathBuf;
22
23use serde::Serialize;
24
25use crate::auth::{AuthProvider, ACCOUNT_ENV, TOKEN_ENV};
26use crate::cache::HERMES_HOME_ENV;
27use crate::error::CloudflareError;
28
29pub const ACCOUNT_ID_ENV: &str = "AUTH_CLOUDFLARE_ACCOUNT_ID";
31pub const API_TOKEN_ENV: &str = "AUTH_CLOUDFLARE_API_TOKEN";
33pub const BASE_URL_ENV: &str = "AUTH_CLOUDFLARE_WORKERS_AI_BASE_URL";
35pub const CACHE_DIR_ENV: &str = "AUTH_CLOUDFLARE_CACHE_DIR";
37pub const CONFIG_ENV: &str = "AUTH_CLOUDFLARE_CONFIG";
39pub const LEGACY_HERMES_TOKEN_ENV: &str = "HERMES_CUSTOM_API_CLOUDFLARE_COM_API_KEY";
41
42pub const ACCOUNT_ID_LEN: usize = 32;
45
46pub const CONFIG_FILE_NAME: &str = "config.json";
48
49#[derive(Clone, PartialEq, Eq)]
55pub struct SecretString(String);
56
57impl SecretString {
58 pub fn new(value: impl Into<String>) -> Self {
61 Self(value.into())
62 }
63
64 pub fn bearer_header(&self) -> String {
66 format!("Bearer {}", self.0)
67 }
68}
69
70impl std::fmt::Debug for SecretString {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.write_str("SecretString(\"<redacted>\")")
73 }
74}
75
76impl std::fmt::Display for SecretString {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.write_str("<redacted>")
79 }
80}
81
82impl AsRef<str> for SecretString {
83 fn as_ref(&self) -> &str {
84 &self.0
85 }
86}
87
88impl From<SecretString> for String {
89 fn from(secret: SecretString) -> Self {
90 secret.0
91 }
92}
93
94impl From<String> for SecretString {
95 fn from(value: String) -> Self {
96 Self(value)
97 }
98}
99
100impl From<&str> for SecretString {
101 fn from(value: &str) -> Self {
102 Self(value.to_string())
103 }
104}
105
106impl Serialize for SecretString {
107 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110 serializer.serialize_str("<redacted>")
111 }
112}
113
114#[derive(Clone, Debug)]
120pub struct Config {
121 account_id: String,
122 api_token: SecretString,
123 base_url: Option<String>,
124 cache_dir: Option<PathBuf>,
125 config_path: PathBuf,
126}
127
128impl Config {
129 pub fn from_env() -> Result<Self, CloudflareError> {
133 ConfigBuilder::new().build()
134 }
135
136 pub fn from_env_lenient() -> Option<Self> {
139 Self::from_env().ok()
140 }
141
142 pub fn account_id(&self) -> &str {
144 &self.account_id
145 }
146
147 pub fn api_token(&self) -> &SecretString {
149 &self.api_token
150 }
151
152 pub fn base_url(&self) -> Result<String, CloudflareError> {
156 Ok(match &self.base_url {
157 Some(url) => url.clone(),
158 None => AuthProvider::new(&self.account_id).base_url(),
159 })
160 }
161
162 pub fn cache_dir(&self) -> PathBuf {
169 match &self.cache_dir {
170 Some(dir) => dir.clone(),
171 None => hermes_home()
172 .join("cache")
173 .join("auth-cloudflare")
174 .join(AuthProvider::new(&self.account_id).cache_slug()),
175 }
176 }
177
178 pub fn config_path(&self) -> PathBuf {
181 self.config_path.clone()
182 }
183}
184
185#[derive(Clone, Debug, Default)]
192pub struct ConfigBuilder {
193 account_id: Option<String>,
194 api_token: Option<String>,
195 base_url: Option<String>,
196 cache_dir: Option<PathBuf>,
197 config_path: Option<PathBuf>,
198}
199
200impl ConfigBuilder {
201 pub fn new() -> Self {
202 Self::default()
203 }
204
205 pub fn account_id(mut self, value: impl Into<String>) -> Self {
207 self.account_id = Some(value.into());
208 self
209 }
210
211 pub fn api_token(mut self, value: impl Into<String>) -> Self {
213 self.api_token = Some(value.into());
214 self
215 }
216
217 pub fn base_url(mut self, value: impl Into<String>) -> Self {
219 self.base_url = Some(value.into());
220 self
221 }
222
223 pub fn cache_dir(mut self, value: impl Into<PathBuf>) -> Self {
225 self.cache_dir = Some(value.into());
226 self
227 }
228
229 pub fn config_path(mut self, value: impl Into<PathBuf>) -> Self {
231 self.config_path = Some(value.into());
232 self
233 }
234
235 pub fn build(self) -> Result<Config, CloudflareError> {
237 let config_path = self
238 .config_path
239 .or_else(|| env_nonempty(CONFIG_ENV).map(PathBuf::from))
240 .unwrap_or_else(|| hermes_home().join("auth-cloudflare").join(CONFIG_FILE_NAME));
241 let file = FileConfig::load(&config_path)?;
242
243 let account_id = normalize(self.account_id)
245 .or_else(|| env_nonempty(ACCOUNT_ID_ENV))
246 .or_else(|| env_nonempty(ACCOUNT_ENV))
247 .or_else(|| normalize(file.account_id))
248 .ok_or_else(|| CloudflareError::MissingEnv {
249 env_var: ACCOUNT_ID_ENV,
250 hint: format!(
251 "export {ACCOUNT_ID_ENV}=<account id> (aliases: {ACCOUNT_ENV}) - found under Workers & Pages → Overview → Account ID"
252 ),
253 })?;
254 if !is_valid_account_id(&account_id) {
255 return Err(CloudflareError::MissingEnv {
256 env_var: ACCOUNT_ID_ENV,
257 hint: format!(
258 "{ACCOUNT_ID_ENV} must be exactly {ACCOUNT_ID_LEN} ASCII hex digits, got {} (\"{account_id}\")",
259 account_id.len()
260 ),
261 });
262 }
263
264 let api_token = normalize(self.api_token)
268 .or_else(|| env_nonempty(API_TOKEN_ENV))
269 .or_else(|| env_nonempty(TOKEN_ENV))
270 .or_else(|| env_nonempty(LEGACY_HERMES_TOKEN_ENV))
271 .or_else(|| {
272 let name = file.api_token_env.as_deref().map(str::trim).filter(|n| !n.is_empty())?;
273 env_nonempty(name)
274 })
275 .ok_or_else(|| CloudflareError::MissingEnv {
276 env_var: API_TOKEN_ENV,
277 hint: format!(
278 "export {API_TOKEN_ENV}=<scoped api token> (aliases: {TOKEN_ENV}, {LEGACY_HERMES_TOKEN_ENV}) - create a token with Account → Workers AI → Write/Edit"
279 ),
280 })?;
281
282 let base_url = normalize(self.base_url)
284 .or_else(|| env_nonempty(BASE_URL_ENV))
285 .or_else(|| normalize(file.base_url));
286
287 let cache_dir = self
289 .cache_dir
290 .or_else(|| env_nonempty(CACHE_DIR_ENV).map(PathBuf::from))
291 .or_else(|| file.cache_dir.map(PathBuf::from));
292
293 Ok(Config {
294 account_id,
295 api_token: SecretString::new(api_token),
296 base_url,
297 cache_dir,
298 config_path,
299 })
300 }
301}
302
303#[derive(serde::Deserialize, Default)]
310struct FileConfig {
311 account_id: Option<String>,
312 base_url: Option<String>,
313 cache_dir: Option<String>,
314 api_token_env: Option<String>,
315}
316
317impl FileConfig {
318 fn load(path: &std::path::Path) -> Result<Self, CloudflareError> {
321 let contents = match std::fs::read_to_string(path) {
322 Ok(contents) => contents,
323 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
324 Err(error) => {
325 return Err(CloudflareError::MissingEnv {
326 env_var: CONFIG_ENV,
327 hint: format!("config file {} is unreadable: {error}", path.display()),
328 });
329 },
330 };
331 serde_json::from_str(&contents).map_err(|error| CloudflareError::MissingEnv {
332 env_var: CONFIG_ENV,
333 hint: format!("config file {} is not valid JSON: {error}", path.display()),
334 })
335 }
336}
337
338fn normalize(value: Option<String>) -> Option<String> {
341 value.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
342}
343
344fn env_nonempty(name: &str) -> Option<String> {
346 std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
347}
348
349fn is_valid_account_id(value: &str) -> bool {
352 value.len() == ACCOUNT_ID_LEN && value.bytes().all(|b| b.is_ascii_hexdigit())
353}
354
355fn hermes_home() -> PathBuf {
358 env_nonempty(HERMES_HOME_ENV).map(PathBuf::from).unwrap_or_else(|| {
359 std::env::var("HOME")
360 .ok()
361 .map(PathBuf::from)
362 .unwrap_or_else(|| PathBuf::from("~"))
363 .join(".hermes")
364 })
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 const ACCOUNT: &str = "0123456789abcdef0123456789abcdef";
374 const OTHER_ACCOUNT: &str = "fedcba9876543210fedcba9876543210";
376 const TOKEN: &str = "cfut_test_synthetic_token_0001";
378
379 const ALL_VARS: &[&str] = &[
381 ACCOUNT_ID_ENV,
382 API_TOKEN_ENV,
383 BASE_URL_ENV,
384 CACHE_DIR_ENV,
385 CONFIG_ENV,
386 ACCOUNT_ENV,
387 TOKEN_ENV,
388 LEGACY_HERMES_TOKEN_ENV,
389 HERMES_HOME_ENV,
390 "HOME",
391 ];
392
393 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
396
397 fn with_env<F, R>(vars: &[(&str, Option<&str>)], f: F) -> R
398 where
399 F: FnOnce() -> R,
400 {
401 let _guard = ENV_LOCK.lock().unwrap();
402 let saved: Vec<(String, Option<String>)> =
403 ALL_VARS.iter().map(|k| ((*k).to_string(), std::env::var(k).ok())).collect();
404 for key in ALL_VARS {
405 std::env::remove_var(key);
406 }
407 for (key, value) in vars {
408 match value {
409 Some(value) => std::env::set_var(key, value),
410 None => std::env::remove_var(key),
411 }
412 }
413 let result = f();
414 for (key, value) in saved {
415 match value {
416 Some(value) => std::env::set_var(&key, value),
417 None => std::env::remove_var(&key),
418 }
419 }
420 result
421 }
422
423 fn temp_config_file(name: &str, contents: &str) -> PathBuf {
425 let dir = std::env::temp_dir().join(format!("auth-cloudflare-config-tests-{}", std::process::id()));
426 std::fs::create_dir_all(&dir).expect("create temp dir");
427 let path = dir.join(name);
428 std::fs::write(&path, contents).expect("write config file");
429 path
430 }
431
432 #[test]
433 fn canonical_env_wins_over_legacy_alias() {
434 with_env(
435 &[
436 (ACCOUNT_ID_ENV, Some(ACCOUNT)),
437 (ACCOUNT_ENV, Some(OTHER_ACCOUNT)),
438 (API_TOKEN_ENV, Some("cfut_test_canonical_token")),
439 (TOKEN_ENV, Some("cfut_test_legacy_token")),
440 ],
441 || {
442 let config = Config::from_env().expect("canonical vars present");
443 assert_eq!(config.account_id(), ACCOUNT);
444 assert_eq!(config.api_token().as_ref(), "cfut_test_canonical_token");
445 assert_eq!(
446 config.base_url().unwrap(),
447 format!("https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/ai/v1")
448 );
449 },
450 );
451 }
452
453 #[test]
454 fn legacy_alias_fallback() {
455 with_env(&[(ACCOUNT_ENV, Some(ACCOUNT)), (TOKEN_ENV, Some(TOKEN))], || {
456 let config = Config::from_env().expect("legacy vars present");
457 assert_eq!(config.account_id(), ACCOUNT);
458 assert_eq!(config.api_token().as_ref(), TOKEN);
459 });
460 with_env(
462 &[
463 (ACCOUNT_ENV, Some(ACCOUNT)),
464 (LEGACY_HERMES_TOKEN_ENV, Some("cfut_test_hermes_key")),
465 ],
466 || {
467 let config = Config::from_env().expect("hermes alias present");
468 assert_eq!(config.account_id(), ACCOUNT);
469 assert_eq!(config.api_token().as_ref(), "cfut_test_hermes_key");
470 },
471 );
472 }
473
474 #[test]
475 fn whitespace_only_treated_missing() {
476 with_env(&[(ACCOUNT_ID_ENV, Some(" \t ")), (API_TOKEN_ENV, Some(TOKEN))], || {
477 let error = Config::from_env().expect_err("whitespace account id is missing");
478 assert!(matches!(error, CloudflareError::MissingEnv { env_var: ACCOUNT_ID_ENV, .. }));
479 });
480 with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some(" "))], || {
481 let error = Config::from_env().expect_err("whitespace token is missing");
482 assert!(matches!(error, CloudflareError::MissingEnv { env_var: API_TOKEN_ENV, .. }));
483 });
484 }
485
486 #[test]
487 fn token_redacted_in_debug_display_and_json() {
488 let token = SecretString::new(TOKEN);
489 assert!(!format!("{token:?}").contains(TOKEN), "Debug must redact the token");
490 assert!(!format!("{token}").contains(TOKEN), "Display must redact the token");
491 let json = serde_json::to_string(&token).expect("serialize");
492 assert_eq!(json, "\"<redacted>\"");
493 assert!(!json.contains(TOKEN), "JSON must redact the token");
494 with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some(TOKEN))], || {
496 let config = Config::from_env().expect("configured");
497 assert!(!format!("{config:?}").contains(TOKEN), "Config Debug must redact the token");
498 });
499 }
500
501 #[test]
502 fn bearer_header_consumes_token_via_as_ref_and_into() {
503 let token = SecretString::new(TOKEN);
504 assert_eq!(token.as_ref(), TOKEN);
505 assert_eq!(token.bearer_header(), format!("Bearer {TOKEN}"));
506 let into_string: String = token.clone().into();
507 assert_eq!(into_string, TOKEN);
508 let from_str: SecretString = TOKEN.into();
509 assert_eq!(from_str.as_ref(), TOKEN);
510 }
511
512 #[test]
513 fn base_url_derived_exactly() {
514 with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some(TOKEN))], || {
515 let config = Config::from_env().expect("configured");
516 assert_eq!(
517 config.base_url().unwrap(),
518 "https://api.cloudflare.com/client/v4/accounts/0123456789abcdef0123456789abcdef/ai/v1"
519 );
520 });
521 }
522
523 #[test]
524 fn base_url_override_wins() {
525 with_env(
526 &[
527 (ACCOUNT_ID_ENV, Some(ACCOUNT)),
528 (API_TOKEN_ENV, Some(TOKEN)),
529 (BASE_URL_ENV, Some("https://example.test/ai/v1")),
530 ],
531 || {
532 let config = Config::from_env().expect("configured");
533 assert_eq!(config.base_url().unwrap(), "https://example.test/ai/v1");
534 },
535 );
536 }
537
538 #[test]
539 fn invalid_account_id_shape_rejected() {
540 for bad in [
542 "abc123",
543 "0123456789abcdef0123456789abcd",
544 "0123456789abcdef 0123456789abcdef",
545 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
546 ] {
547 with_env(&[(ACCOUNT_ID_ENV, Some(bad)), (API_TOKEN_ENV, Some(TOKEN))], || {
548 let error = Config::from_env().expect_err("invalid shape must be rejected");
549 assert!(matches!(error, CloudflareError::MissingEnv { env_var: ACCOUNT_ID_ENV, .. }));
550 assert!(Config::from_env_lenient().is_none(), "lenient must drop invalid shape");
551 });
552 }
553 }
554
555 #[test]
556 fn account_id_accepts_uppercase_hex() {
557 with_env(
558 &[
559 (ACCOUNT_ID_ENV, Some("0123456789ABCDEF0123456789ABCDEF")),
560 (API_TOKEN_ENV, Some(TOKEN)),
561 ],
562 || {
563 assert!(Config::from_env().is_ok(), "uppercase hex is a valid account id");
564 },
565 );
566 }
567
568 #[test]
569 fn config_file_provides_account_and_token_env_name() {
570 let path = temp_config_file(
571 "config-env-name.json",
572 &format!(r#"{{"account_id":"{ACCOUNT}","api_token_env":"MY_CF_TOKEN_VAR"}}"#),
573 );
574 with_env(
575 &[(CONFIG_ENV, Some(path.to_str().unwrap())), ("MY_CF_TOKEN_VAR", Some(TOKEN))],
576 || {
577 let config = Config::from_env().expect("config file fallback");
578 assert_eq!(config.account_id(), ACCOUNT);
579 assert_eq!(config.api_token().as_ref(), TOKEN);
580 assert_eq!(config.config_path(), path);
581 },
582 );
583 }
584
585 #[test]
586 fn config_file_token_value_is_never_read() {
587 let path = temp_config_file(
590 "config-token-ignored.json",
591 &format!(r#"{{"account_id":"{ACCOUNT}","api_token":"cfut_test_should_be_ignored"}}"#),
592 );
593 with_env(&[(CONFIG_ENV, Some(path.to_str().unwrap()))], || {
594 let error = Config::from_env().expect_err("token value in file must not satisfy resolution");
595 assert!(matches!(error, CloudflareError::MissingEnv { env_var: API_TOKEN_ENV, .. }));
596 });
597 }
598
599 #[test]
600 fn config_file_missing_is_not_an_error() {
601 with_env(
602 &[
603 (ACCOUNT_ID_ENV, Some(ACCOUNT)),
604 (API_TOKEN_ENV, Some(TOKEN)),
605 (CONFIG_ENV, Some("/nonexistent/auth-cloudflare/config.json")),
606 ],
607 || {
608 let config = Config::from_env().expect("missing config file is fine");
609 assert_eq!(config.account_id(), ACCOUNT);
610 },
611 );
612 }
613
614 #[test]
615 fn cache_dir_defaults_to_hermes_home_slug() {
616 with_env(
617 &[
618 (ACCOUNT_ID_ENV, Some(ACCOUNT)),
619 (API_TOKEN_ENV, Some(TOKEN)),
620 (HERMES_HOME_ENV, Some("/tmp/auth-cloudflare-hermes-home")),
621 ],
622 || {
623 let config = Config::from_env().expect("configured");
624 let expected = PathBuf::from("/tmp/auth-cloudflare-hermes-home")
625 .join("cache")
626 .join("auth-cloudflare")
627 .join(AuthProvider::new(ACCOUNT).cache_slug());
628 assert_eq!(config.cache_dir(), expected);
629 },
630 );
631 }
632
633 #[test]
634 fn cache_dir_override_wins() {
635 with_env(
636 &[
637 (ACCOUNT_ID_ENV, Some(ACCOUNT)),
638 (API_TOKEN_ENV, Some(TOKEN)),
639 (CACHE_DIR_ENV, Some("/tmp/custom-cache")),
640 ],
641 || {
642 let config = Config::from_env().expect("configured");
643 assert_eq!(config.cache_dir(), PathBuf::from("/tmp/custom-cache"));
644 },
645 );
646 }
647
648 #[test]
649 fn config_path_defaults_under_hermes_home() {
650 with_env(
651 &[
652 (ACCOUNT_ID_ENV, Some(ACCOUNT)),
653 (API_TOKEN_ENV, Some(TOKEN)),
654 (HERMES_HOME_ENV, Some("/tmp/auth-cloudflare-hermes-home")),
655 ],
656 || {
657 let config = Config::from_env().expect("configured");
658 let expected = PathBuf::from("/tmp/auth-cloudflare-hermes-home")
659 .join("auth-cloudflare")
660 .join("config.json");
661 assert_eq!(config.config_path(), expected);
662 },
663 );
664 }
665
666 #[test]
667 fn explicit_constructor_wins_over_env() {
668 with_env(
669 &[
670 (ACCOUNT_ID_ENV, Some(OTHER_ACCOUNT)),
671 (API_TOKEN_ENV, Some("cfut_test_env_token")),
672 ],
673 || {
674 let config = ConfigBuilder::new()
675 .account_id(ACCOUNT)
676 .api_token("cfut_test_explicit_token")
677 .build()
678 .expect("explicit values");
679 assert_eq!(config.account_id(), ACCOUNT);
680 assert_eq!(config.api_token().as_ref(), "cfut_test_explicit_token");
681 },
682 );
683 }
684
685 #[test]
686 fn from_env_lenient_returns_none_when_missing() {
687 with_env(&[], || {
688 assert!(Config::from_env_lenient().is_none());
689 });
690 with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT)), (API_TOKEN_ENV, Some(TOKEN))], || {
691 let config = Config::from_env_lenient().expect("complete env");
692 assert_eq!(config.account_id(), ACCOUNT);
693 });
694 }
695
696 #[test]
697 fn missing_env_error_names_the_var() {
698 with_env(&[(API_TOKEN_ENV, Some(TOKEN))], || {
699 let error = Config::from_env().expect_err("account missing");
700 assert!(error.to_string().contains(ACCOUNT_ID_ENV));
701 });
702 with_env(&[(ACCOUNT_ID_ENV, Some(ACCOUNT))], || {
703 let error = Config::from_env().expect_err("token missing");
704 assert!(error.to_string().contains(API_TOKEN_ENV));
705 assert!(!error.to_string().contains("cfut_"), "error must not echo token prefixes");
706 });
707 }
708}