返回 CodeWhale
lib.rs
根目录 / crates / cloud-facts / src / lib.rs
1 //! Cloud facts client: fetch `https://codewhale.net/api/facts/v1/<channel>`,
2 //! verify the Ed25519 envelope against the keys pinned in
3 //! `codewhale_config::cloud_facts::keys`, cache it under
4 //! `$CODEWHALE_HOME/facts/cloud-facts.json`, and install the scoped view as the
5 //! process-wide overlay. Modeled on the TUI's `models_dev_live` producer.
6 //!
7 //! Guarantees:
8 //! - Never a startup dependency: [`maybe_load_persisted_cache`] is a bounded
9 //! synchronous disk read; all network happens in [`spawn_background_refresh`].
10 //! - Off by default (`[cloud_facts].enabled = false`); `CODEWHALE_CLOUD_FACTS=1`
11 //! flips it, `CODEWHALE_DISABLE_CLOUD_FACTS=1` beats everything, CI markers
12 //! suppress the fetch.
13 //! - The disk cache is re-verified on every load; untrusted bytes are cleared while the rollback floor is retained.
14 //! - The fetch sends only a fixed user agent and `If-None-Match`; no
15 //! identifiers, cookies, or query parameters (PRD §5).
16 //! - With no active pinned key the layer is inert even when enabled.
17
18 use std::io::Read as _;
19 use std::path::{Path, PathBuf};
20 use std::sync::Arc;
21 use std::time::Duration;
22
23 use codewhale_config::catalog::now_unix;
24 use codewhale_config::cloud_facts::{
25 CloudFactsState, CloudFactsStatus, FactsOrigin, FactsRejection, TrustedKey, VerifiedFacts,
26 overlay, scoped_view, verify_envelope,
27 };
28 use codewhale_config::persistence::atomic_write;
29 use serde::{Deserialize, Serialize};
30
31 /// `{channel}` is replaced with the channel slug.
32 pub const DEFAULT_URL_TEMPLATE: &str = "https://codewhale.net/api/facts/v1/{channel}";
33 /// Refresh interval for a verified payload (6 h).
34 pub const DEFAULT_TTL_SECS: u64 = 6 * 60 * 60;
35 /// Bounded HTTP budget.
36 pub const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
37 pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
38 /// Largest response body accepted.
39 pub const MAX_BODY_BYTES: usize = codewhale_config::cloud_facts::MAX_ENVELOPE_BYTES;
40 /// Fixed, identifier-free user agent.
41 pub const USER_AGENT: &str = concat!("CodeWhale/", env!("CARGO_PKG_VERSION"), " (+cloud-facts)");
42 /// State subdir + file under `$CODEWHALE_HOME`.
43 pub const STATE_SUBDIR: &str = "facts";
44 pub const CACHE_FILE: &str = "cloud-facts.json";
45 const CACHE_SCHEMA_VERSION: u32 = 2;
46 const MAX_SOURCE_BYTES: usize = 4096;
47 const MAX_CACHE_BYTES: usize = MAX_BODY_BYTES * 6 + 32 * 1024;
48 static REFRESH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
49 const BACKOFF_BASE_SECS: u64 = 10 * 60;
50
51 /// Env: `1`/`0` overrides `[cloud_facts].enabled`.
52 pub const ENV_ENABLED: &str = "CODEWHALE_CLOUD_FACTS";
53 /// Env: hard kill switch (truthy) — beats config and `ENV_ENABLED`.
54 pub const ENV_DISABLE: &str = "CODEWHALE_DISABLE_CLOUD_FACTS";
55 /// Env: full URL override (may contain `{channel}`).
56 pub const ENV_URL: &str = "CODEWHALE_CLOUD_FACTS_URL";
57 /// Env: channel slug override.
58 pub const ENV_CHANNEL: &str = "CODEWHALE_CLOUD_FACTS_CHANNEL";
59 /// Env: read the envelope from a local file instead of the network.
60 pub const ENV_PATH: &str = "CODEWHALE_CLOUD_FACTS_PATH";
61 const CI_MARKERS: &[&str] = &[
62 "CI",
63 "GITHUB_ACTIONS",
64 "GITLAB_CI",
65 "BUILDKITE",
66 "CIRCLECI",
67 "JENKINS_URL",
68 "TEAMCITY_VERSION",
69 "TF_BUILD",
70 ];
71
72 /// Resolved runtime settings (config + env).
73 #[derive(Debug, Clone, PartialEq, Eq)]
74 pub struct Settings {
75 pub enabled: bool,
76 pub channel: String,
77 pub url: Option<String>,
78 pub ttl_secs: u64,
79 /// Explicit cache file (tests); otherwise `$CODEWHALE_HOME/facts/cloud-facts.json`.
80 pub cache_path: Option<PathBuf>,
81 /// Local envelope path (`ENV_PATH`); skips the network.
82 pub local_path: Option<PathBuf>,
83 }
84
85 impl Default for Settings {
86 fn default() -> Self {
87 Self {
88 enabled: false,
89 channel: "stable".to_string(),
90 url: None,
91 ttl_secs: DEFAULT_TTL_SECS,
92 cache_path: None,
93 local_path: None,
94 }
95 }
96 }
97
98 fn env_truthy(name: &str) -> Option<bool> {
99 let value = std::env::var(name).ok()?;
100 match value.trim().to_ascii_lowercase().as_str() {
101 "1" | "true" | "yes" | "on" => Some(true),
102 "0" | "false" | "no" | "off" => Some(false),
103 _ => None,
104 }
105 }
106
107 impl Settings {
108 /// Apply env overrides on top of config-derived settings.
109 #[must_use]
110 pub fn resolve(mut self) -> Self {
111 if let Some(enabled) = env_truthy(ENV_ENABLED) {
112 self.enabled = enabled;
113 }
114 if let Ok(channel) = std::env::var(ENV_CHANNEL) {
115 let channel = channel.trim();
116 if valid_channel(channel) {
117 self.channel = channel.to_string();
118 }
119 }
120 if let Ok(url) = std::env::var(ENV_URL) {
121 let url = url.trim();
122 if !url.is_empty() {
123 self.url = Some(url.to_string());
124 }
125 }
126 if let Ok(path) = std::env::var(ENV_PATH) {
127 let path = path.trim();
128 if !path.is_empty() {
129 self.local_path = Some(PathBuf::from(path));
130 }
131 }
132 if hard_disabled() {
133 self.enabled = false;
134 }
135 self.ttl_secs = self.ttl_secs.max(60);
136 self
137 }
138
139 /// The effective envelope URL.
140 #[must_use]
141 pub fn url(&self) -> String {
142 self.url
143 .as_deref()
144 .unwrap_or(DEFAULT_URL_TEMPLATE)
145 .replace("{channel}", &self.channel)
146 }
147
148 fn cache_file(&self) -> Option<PathBuf> {
149 self.cache_path.clone().or_else(|| {
150 let path = cache_path()?;
151 if self.channel == "stable" {
152 Some(path)
153 } else {
154 Some(path.with_file_name(format!("cloud-facts-{}.json", self.channel)))
155 }
156 })
157 }
158 }
159
160 /// Channel slugs are `[a-z0-9][a-z0-9-]{0,31}`.
161 #[must_use]
162 pub fn valid_channel(slug: &str) -> bool {
163 let bytes = slug.as_bytes();
164 (1..=32).contains(&bytes.len())
165 && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
166 && bytes
167 .iter()
168 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
169 }
170
171 /// Production network policy. Local fixtures inject their transport explicitly.
172 #[must_use]
173 pub fn fetch_suppressed() -> bool {
174 CI_MARKERS.iter().any(|name| {
175 env_truthy(name).unwrap_or_else(|| std::env::var(name).is_ok_and(|v| !v.trim().is_empty()))
176 })
177 }
178
179 /// Default cache path under the CodeWhale state root.
180 #[must_use]
181 pub fn cache_path() -> Option<PathBuf> {
182 codewhale_config::resolve_state_dir(STATE_SUBDIR)
183 .ok()
184 .map(|dir| dir.join(CACHE_FILE))
185 }
186
187 fn hard_disabled() -> bool {
188 overlay::hard_disabled()
189 }
190
191 fn source(settings: &Settings) -> Result<String, RefreshError> {
192 if !valid_channel(&settings.channel) {
193 return Err(RefreshError::InvalidSettings("invalid channel".into()));
194 }
195 if let Some(path) = &settings.local_path {
196 let path = if path.is_absolute() {
197 path.clone()
198 } else {
199 std::env::current_dir()
200 .map_err(|e| RefreshError::Io(e.to_string()))?
201 .join(path)
202 };
203 let value = format!("file:{}", path.display());
204 if value.len() > MAX_SOURCE_BYTES {
205 return Err(RefreshError::TooLarge(value.len()));
206 }
207 return Ok(value);
208 }
209 let raw = settings.url();
210 if raw.len() > MAX_SOURCE_BYTES {
211 return Err(RefreshError::TooLarge(raw.len()));
212 }
213 let url = reqwest::Url::parse(&raw)
214 .map_err(|_| RefreshError::InvalidSettings("invalid URL".into()))?;
215 if !matches!(url.scheme(), "http" | "https")
216 || url.host_str().is_none()
217 || !url.username().is_empty()
218 || url.password().is_some()
219 || url.fragment().is_some()
220 || url.query().is_some()
221 {
222 return Err(RefreshError::InvalidSettings(
223 "URL must be HTTP(S), without credentials, query or fragment".into(),
224 ));
225 }
226 Ok(url.to_string())
227 }
228
229 fn source_identity(settings: &Settings, keys: &[TrustedKey]) -> Result<String, RefreshError> {
230 // Trust inputs are public pins, not credentials. Including them invalidates
231 // a previously issued ticket when a test or a future reload changes trust.
232 Ok(format!(
233 "{}\n{}\n{}\n{}\n{:?}",
234 settings.channel,
235 source(settings)?,
236 settings.ttl_secs,
237 codewhale_config::cloud_facts::current_version(),
238 keys
239 ))
240 }
241
242 /// Publish admitted settings synchronously, before spawning work. Refreshing
243 /// an old Settings value can never re-enable or change this authority.
244 pub fn configure(settings: &Settings) {
245 configure_with_keys(settings, codewhale_config::cloud_facts::TRUSTED_KEYS);
246 }
247
248 fn configure_with_keys(settings: &Settings, keys: &[TrustedKey]) {
249 if !settings.enabled || hard_disabled() {
250 overlay::configure(false, "");
251 return;
252 }
253 let identity = match source_identity(settings, keys) {
254 Ok(identity) => identity,
255 Err(_) => {
256 overlay::configure(false, "");
257 return;
258 }
259 };
260 if let Some(ticket) = overlay::configure(true, &identity)
261 && !keys
262 .iter()
263 .any(|key| key.status == codewhale_config::cloud_facts::KeyStatus::Active)
264 {
265 overlay::publish(
266 &ticket,
267 None,
268 state_status(CloudFactsState::Inert, None, ""),
269 );
270 }
271 }
272
273 fn ticket(
274 settings: &Settings,
275 keys: &[TrustedKey],
276 ) -> Result<overlay::OverlayTicket, RefreshError> {
277 if !settings.enabled || hard_disabled() {
278 return Err(RefreshError::Disabled);
279 }
280 if !keys
281 .iter()
282 .any(|key| key.status == codewhale_config::cloud_facts::KeyStatus::Active)
283 {
284 return Err(RefreshError::Inert);
285 }
286 overlay::current_ticket(&source_identity(settings, keys)?).ok_or(RefreshError::Superseded)
287 }
288
289 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
290 struct PersistedCache {
291 schema_version: u32,
292 channel: String,
293 url: String,
294 #[serde(default)]
295 source_identity: String,
296 fetched_at: u64,
297 #[serde(default)]
298 etag: Option<String>,
299 #[serde(default)]
300 highest_seen_version: Option<u64>,
301 #[serde(default)]
302 backoff_until: Option<u64>,
303 #[serde(default)]
304 failures: u32,
305 #[serde(default)]
306 envelope: String,
307 }
308
309 fn read_bounded_regular(path: &Path, limit: usize) -> Result<Vec<u8>, RefreshError> {
310 let mut options = std::fs::OpenOptions::new();
311 options.read(true);
312 #[cfg(unix)]
313 {
314 use std::os::unix::fs::OpenOptionsExt as _;
315 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
316 }
317 #[cfg(windows)]
318 {
319 use std::os::windows::fs::OpenOptionsExt as _;
320 options.custom_flags(0x0020_0000);
321 }
322 let file = options
323 .open(path)
324 .map_err(|e| RefreshError::Io(e.to_string()))?;
325 let metadata = file
326 .metadata()
327 .map_err(|e| RefreshError::Io(e.to_string()))?;
328 let mut regular = metadata.is_file();
329 #[cfg(unix)]
330 {
331 use std::os::unix::fs::MetadataExt as _;
332 regular &= metadata.nlink() == 1;
333 }
334 #[cfg(windows)]
335 {
336 use std::os::windows::fs::MetadataExt as _;
337 use std::os::windows::io::AsRawHandle as _;
338 use windows_sys::Win32::Storage::FileSystem::{
339 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
340 };
341 let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
342 // SAFETY: this is the live handle already opened without following
343 // reparse points; `information` is writable for the synchronous call.
344 let inspected =
345 unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) };
346 regular &= metadata.file_attributes() & 0x0000_0400 == 0
347 && inspected != 0
348 && information.nNumberOfLinks == 1;
349 }
350 if !regular {
351 return Err(RefreshError::Io(
352 "facts file must be a regular file with one link".into(),
353 ));
354 }
355 if metadata.len() > limit as u64 {
356 return Err(RefreshError::TooLarge(limit.saturating_add(1)));
357 }
358 let mut bytes = Vec::new();
359 file.take(limit.saturating_add(1) as u64)
360 .read_to_end(&mut bytes)
361 .map_err(|e| RefreshError::Io(e.to_string()))?;
362 if bytes.len() > limit {
363 return Err(RefreshError::TooLarge(bytes.len()));
364 }
365 Ok(bytes)
366 }
367
368 fn load_cache(path: &Path) -> Option<PersistedCache> {
369 let bytes = read_bounded_regular(path, MAX_CACHE_BYTES).ok()?;
370 let cache: PersistedCache = serde_json::from_slice(&bytes).ok()?;
371 (cache.schema_version == CACHE_SCHEMA_VERSION
372 && valid_channel(&cache.channel)
373 && cache.envelope.len() <= MAX_BODY_BYTES
374 && cache.url.len() <= MAX_SOURCE_BYTES
375 && cache.source_identity.len() <= 16 * MAX_SOURCE_BYTES
376 && cache
377 .etag
378 .as_ref()
379 .is_none_or(|etag| etag.len() <= MAX_SOURCE_BYTES))
380 .then_some(cache)
381 }
382
383 fn save_cache(path: &Path, cache: &PersistedCache) {
384 if cache.envelope.len() > MAX_BODY_BYTES
385 || cache.url.len() > MAX_SOURCE_BYTES
386 || cache.source_identity.len() > 16 * MAX_SOURCE_BYTES
387 || cache
388 .etag
389 .as_ref()
390 .is_some_and(|etag| etag.len() > MAX_SOURCE_BYTES)
391 {
392 return;
393 }
394 if let Ok(bytes) = serde_json::to_vec(cache)
395 && bytes.len() <= MAX_CACHE_BYTES
396 && let Err(err) = atomic_write(path, &bytes)
397 {
398 tracing::debug!(target: "cloud_facts", error = %err, "cache write failed");
399 }
400 }
401
402 #[derive(Debug, Clone, PartialEq, Eq)]
403 pub enum RefreshError {
404 Disabled,
405 Inert,
406 Suppressed,
407 Superseded,
408 InvalidSettings(String),
409 BackingOff { until: u64 },
410 Network(String),
411 HttpStatus(u16),
412 TooLarge(usize),
413 Rejected(FactsRejection),
414 Io(String),
415 }
416 impl std::fmt::Display for RefreshError {
417 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418 match self {
419 Self::Disabled => write!(f, "cloud facts disabled"),
420 Self::Inert => write!(f, "no active trusted key"),
421 Self::Suppressed => write!(f, "production network fetch suppressed by CI"),
422 Self::Superseded => write!(f, "facts settings changed before publication"),
423 Self::InvalidSettings(e) => write!(f, "invalid settings: {e}"),
424 Self::BackingOff { until } => write!(f, "backing off until {until}"),
425 Self::Network(e) => write!(f, "network: {e}"),
426 Self::HttpStatus(code) => write!(f, "HTTP {code}"),
427 Self::TooLarge(n) => write!(f, "response too large ({n} bytes)"),
428 Self::Rejected(e) => write!(f, "{e}"),
429 Self::Io(e) => write!(f, "io: {e}"),
430 }
431 }
432 }
433
434 #[derive(Debug, Clone, PartialEq, Eq)]
435 pub enum RefreshOutcome {
436 NotModified { facts_version: Option<u64> },
437 Updated { facts_version: u64 },
438 Fresh { facts_version: Option<u64> },
439 NoFacts,
440 }
441
442 fn state_status(
443 state: CloudFactsState,
444 etag: Option<String>,
445 source_label: &str,
446 ) -> CloudFactsStatus {
447 CloudFactsStatus {
448 state,
449 last_attempt: Some(now_unix()),
450 etag,
451 source_label: source_label.into(),
452 }
453 }
454
455 fn verify_with(
456 bytes: &[u8],
457 settings: &Settings,
458 highest: Option<u64>,
459 keys: &[TrustedKey],
460 now: u64,
461 ) -> Result<VerifiedFacts, FactsRejection> {
462 verify_envelope(
463 bytes,
464 &settings.channel,
465 &codewhale_config::cloud_facts::current_version(),
466 highest.max(overlay::highest_seen(&settings.channel)),
467 keys,
468 now,
469 )
470 }
471
472 fn publish_verified(
473 ticket: &overlay::OverlayTicket,
474 verified: &VerifiedFacts,
475 origin: FactsOrigin,
476 cache: &PersistedCache,
477 path: Option<&Path>,
478 now: u64,
479 ) -> Result<u64, RefreshError> {
480 let scoped = scoped_view(
481 verified,
482 &codewhale_config::cloud_facts::current_version(),
483 now,
484 );
485 let (patches, defaults, announcements) = scoped.item_counts();
486 let version = scoped.facts_version;
487 let status = state_status(
488 CloudFactsState::Verified {
489 channel: scoped.channel.clone(),
490 facts_version: version,
491 key_id: scoped.key_id.clone(),
492 fetched_at: cache.fetched_at,
493 origin,
494 stale: scoped.stale,
495 patches,
496 defaults,
497 announcements,
498 },
499 cache.etag.clone(),
500 &cache.url,
501 );
502 if overlay::publish_with(ticket, Some(scoped), status, || {
503 if let Some(path) = path {
504 save_cache(path, cache);
505 }
506 }) {
507 Ok(version)
508 } else {
509 Err(RefreshError::Superseded)
510 }
511 }
512
513 fn cache_for(settings: &Settings, keys: &[TrustedKey]) -> Result<PersistedCache, RefreshError> {
514 let identity = source_identity(settings, keys)?;
515 let mut cache = settings
516 .cache_file()
517 .as_deref()
518 .and_then(load_cache)
519 .filter(|cache| cache.channel == settings.channel)
520 .unwrap_or_default();
521 // The persisted high-water hint is unsigned. Recover any stronger floor
522 // from the authenticated body before a source switch discards its bytes,
523 // including refreshes that did not first seed the process overlay.
524 if !cache.envelope.is_empty()
525 && let Ok(verified) =
526 verify_with(cache.envelope.as_bytes(), settings, None, keys, now_unix())
527 {
528 cache.highest_seen_version = cache
529 .highest_seen_version
530 .max(Some(verified.facts.facts_version));
531 }
532 if cache.source_identity != identity {
533 // Retain the channel rollback floor while discarding another source's
534 // validators, body and retry state. Channels use separate default files.
535 let floor = cache.highest_seen_version;
536 cache = PersistedCache {
537 highest_seen_version: floor,
538 ..PersistedCache::default()
539 };
540 }
541 cache.highest_seen_version = cache
542 .highest_seen_version
543 .max(overlay::highest_seen(&settings.channel));
544 // Local cache metadata is only a hint. A forged/future timestamp must
545 // not grant an indefinitely fresh view or suppress all future refreshes.
546 let now = now_unix();
547 if cache.fetched_at > now {
548 cache.fetched_at = 0;
549 cache.backoff_until = None;
550 }
551 let max_backoff = (BACKOFF_BASE_SECS << 9).min(settings.ttl_secs);
552 if cache
553 .backoff_until
554 .is_some_and(|until| until > now.saturating_add(max_backoff))
555 {
556 cache.backoff_until = None;
557 }
558 cache.schema_version = CACHE_SCHEMA_VERSION;
559 cache.channel = settings.channel.clone();
560 cache.source_identity = identity;
561 cache.url = source(settings)?;
562 Ok(cache)
563 }
564
565 pub fn maybe_load_persisted_cache(settings: &Settings) -> Option<u64> {
566 maybe_load_persisted_cache_with_keys(settings, codewhale_config::cloud_facts::TRUSTED_KEYS)
567 }
568 fn maybe_load_persisted_cache_with_keys(settings: &Settings, keys: &[TrustedKey]) -> Option<u64> {
569 let ticket = ticket(settings, keys).ok()?;
570 let cache = cache_for(settings, keys).ok()?;
571 if cache.envelope.is_empty() {
572 return None;
573 }
574 let now = now_unix();
575 match verify_with(
576 cache.envelope.as_bytes(),
577 settings,
578 cache.highest_seen_version,
579 keys,
580 now,
581 ) {
582 Ok(mut verified) => {
583 verified.stale |= now.saturating_sub(cache.fetched_at) >= settings.ttl_secs;
584 publish_verified(
585 &ticket,
586 &verified,
587 FactsOrigin::DiskCache,
588 &cache,
589 None,
590 now,
591 )
592 .ok()
593 }
594 Err(reason) => {
595 // Retain the channel's rollback floor, replacing the rejected body
596 // through the same generation-guarded cache publication boundary.
597 let mut rejected = cache.clone();
598 rejected.envelope.clear();
599 rejected.etag = None;
600 overlay::publish_with(
601 &ticket,
602 None,
603 state_status(
604 CloudFactsState::Rejected {
605 reason: reason.to_string(),
606 at: now,
607 },
608 None,
609 &cache.url,
610 ),
611 || {
612 if let Some(path) = settings.cache_file() {
613 save_cache(&path, &rejected);
614 }
615 },
616 );
617 None
618 }
619 }
620 }
621
622 enum Fetched {
623 NotModified,
624 NotFound,
625 Body {
626 bytes: Vec<u8>,
627 etag: Option<String>,
628 },
629 }
630
631 async fn fetch(url: String, etag: Option<String>) -> Result<Fetched, RefreshError> {
632 let client = codewhale_release::tls::reqwest_client_builder()
633 .timeout(FETCH_TIMEOUT)
634 .connect_timeout(CONNECT_TIMEOUT)
635 .user_agent(USER_AGENT)
636 .redirect(reqwest::redirect::Policy::none())
637 .build()
638 .map_err(|e| RefreshError::Network(e.to_string()))?;
639 let mut request = client.get(url).header("Accept", "application/json");
640 if let Some(etag) = etag {
641 request = request.header("If-None-Match", etag);
642 }
643 let mut response = request
644 .send()
645 .await
646 .map_err(|e| RefreshError::Network(e.to_string()))?;
647 let status = response.status().as_u16();
648 if status == 304 {
649 return Ok(Fetched::NotModified);
650 }
651 if status == 404 {
652 return Ok(Fetched::NotFound);
653 }
654 if !(200..300).contains(&status) {
655 return Err(RefreshError::HttpStatus(status));
656 }
657 if response
658 .content_length()
659 .is_some_and(|len| len > MAX_BODY_BYTES as u64)
660 {
661 return Err(RefreshError::TooLarge(MAX_BODY_BYTES + 1));
662 }
663 let etag = response
664 .headers()
665 .get("etag")
666 .and_then(|v| v.to_str().ok())
667 .filter(|value| value.len() <= MAX_SOURCE_BYTES)
668 .map(str::to_string);
669 let mut bytes = Vec::new();
670 while let Some(chunk) = response
671 .chunk()
672 .await
673 .map_err(|e| RefreshError::Network(e.to_string()))?
674 {
675 let size = bytes.len().saturating_add(chunk.len());
676 if size > MAX_BODY_BYTES {
677 return Err(RefreshError::TooLarge(size));
678 }
679 bytes.extend_from_slice(&chunk);
680 }
681 Ok(Fetched::Body { bytes, etag })
682 }
683
684 /// Uses only admitted settings; callers must configure synchronously first.
685 pub async fn refresh(settings: &Settings, force: bool) -> Result<RefreshOutcome, RefreshError> {
686 refresh_with_keys(settings, force, codewhale_config::cloud_facts::TRUSTED_KEYS).await
687 }
688 async fn refresh_with_keys(
689 settings: &Settings,
690 force: bool,
691 keys: &[TrustedKey],
692 ) -> Result<RefreshOutcome, RefreshError> {
693 refresh_using(settings, force, keys, None, fetch_suppressed(), fetch).await
694 }
695
696 // Tests inject an explicit transport/policy, leaving the production CI gate
697 // intact. Their dependencies cannot override settings/trust admission.
698 async fn refresh_using<F, Fut>(
699 settings: &Settings,
700 force: bool,
701 keys: &[TrustedKey],
702 admitted: Option<overlay::OverlayTicket>,
703 suppress_network: bool,
704 transport: F,
705 ) -> Result<RefreshOutcome, RefreshError>
706 where
707 F: FnOnce(String, Option<String>) -> Fut,
708 Fut: std::future::Future<Output = Result<Fetched, RefreshError>>,
709 {
710 let ticket = admitted.map(Ok).unwrap_or_else(|| ticket(settings, keys))?;
711 let _refresh = REFRESH_LOCK.lock().await;
712 // A queued old request must fail without even reading a file.
713 let _ = self::ticket(settings, keys)?;
714 if !overlay::is_current(&ticket) {
715 return Err(RefreshError::Superseded);
716 }
717 let mut cache = cache_for(settings, keys)?;
718 let path = settings.cache_file();
719 let now = now_unix();
720 let fetched = if let Some(local) = &settings.local_path {
721 read_bounded_regular(local, MAX_BODY_BYTES).map(|bytes| Fetched::Body { bytes, etag: None })
722 } else {
723 if suppress_network {
724 return Err(RefreshError::Suppressed);
725 }
726 if !force {
727 if let Some(until) = cache.backoff_until
728 && now < until
729 {
730 return Err(RefreshError::BackingOff { until });
731 }
732 if !cache.envelope.is_empty()
733 && now.saturating_sub(cache.fetched_at) < settings.ttl_secs
734 && let Ok(verified) = verify_with(
735 cache.envelope.as_bytes(),
736 settings,
737 cache.highest_seen_version,
738 keys,
739 now,
740 )
741 {
742 let version = publish_verified(
743 &ticket,
744 &verified,
745 FactsOrigin::DiskCache,
746 &cache,
747 None,
748 now,
749 )?;
750 return Ok(RefreshOutcome::Fresh {
751 facts_version: Some(version),
752 });
753 }
754 }
755 transport(
756 cache.url.clone(),
757 cache.etag.clone().filter(|_| !cache.envelope.is_empty()),
758 )
759 .await
760 };
761 let mut not_modified = false;
762 let (bytes, etag) = match fetched {
763 Ok(Fetched::NotModified) => {
764 not_modified = true;
765 (cache.envelope.as_bytes().to_vec(), cache.etag.clone())
766 }
767 Ok(Fetched::NotFound) => {
768 cache.envelope.clear();
769 cache.etag = None;
770 cache.failures = 0;
771 cache.backoff_until = None;
772 cache.fetched_at = now;
773 if !overlay::publish_with(
774 &ticket,
775 None,
776 state_status(CloudFactsState::BundledOnly, None, &cache.url),
777 || {
778 if let Some(path) = &path {
779 save_cache(path, &cache);
780 }
781 },
782 ) {
783 return Err(RefreshError::Superseded);
784 }
785 return Ok(RefreshOutcome::NoFacts);
786 }
787 Ok(Fetched::Body { bytes, etag }) => (bytes, etag),
788 Err(err) => {
789 cache.failures = cache.failures.saturating_add(1);
790 cache.backoff_until = Some(
791 now.saturating_add(
792 (BACKOFF_BASE_SECS << cache.failures.min(10).saturating_sub(1))
793 .min(settings.ttl_secs),
794 ),
795 );
796 // Reverify retained facts on failure too; the status must never
797 // hide a revoked or expired overlay behind a prior successful fetch.
798 let kept = verify_with(
799 cache.envelope.as_bytes(),
800 settings,
801 cache.highest_seen_version,
802 keys,
803 now,
804 )
805 .ok()
806 .map(|mut verified| {
807 verified.stale |= now.saturating_sub(cache.fetched_at) >= settings.ttl_secs;
808 scoped_view(
809 &verified,
810 &codewhale_config::cloud_facts::current_version(),
811 now,
812 )
813 });
814 let keeping = kept
815 .as_ref()
816 .filter(|facts| !facts.stale)
817 .map(|facts| facts.facts_version);
818 if !overlay::publish_with(
819 &ticket,
820 kept,
821 state_status(
822 CloudFactsState::Failed {
823 last_error: err.to_string(),
824 at: now,
825 keeping,
826 },
827 cache.etag.clone(),
828 &cache.url,
829 ),
830 || {
831 if let Some(path) = &path {
832 save_cache(path, &cache);
833 }
834 },
835 ) {
836 return Err(RefreshError::Superseded);
837 }
838 return Err(err);
839 }
840 };
841 // 304 is a transport optimization, never a trust decision. This also
842 // rejects a 304 without an authenticated matching cached envelope.
843 match verify_with(
844 &bytes,
845 settings,
846 cache.highest_seen_version,
847 keys,
848 now_unix(),
849 ) {
850 Ok(verified) => {
851 cache.fetched_at = now_unix();
852 cache.etag = etag;
853 cache.failures = 0;
854 cache.backoff_until = None;
855 cache.highest_seen_version = Some(
856 cache
857 .highest_seen_version
858 .unwrap_or(0)
859 .max(verified.facts.facts_version),
860 );
861 cache.envelope =
862 String::from_utf8(bytes).map_err(|e| RefreshError::Io(e.to_string()))?;
863 let origin = if settings.local_path.is_some() {
864 FactsOrigin::LocalFile
865 } else {
866 FactsOrigin::Network
867 };
868 let version = publish_verified(
869 &ticket,
870 &verified,
871 origin,
872 &cache,
873 path.as_deref(),
874 now_unix(),
875 )?;
876 Ok(if not_modified {
877 RefreshOutcome::NotModified {
878 facts_version: Some(version),
879 }
880 } else {
881 RefreshOutcome::Updated {
882 facts_version: version,
883 }
884 })
885 }
886 Err(reason) => {
887 // Drop the now-untrusted body, retain rollback floor, and do not
888 // let the next response reuse its ETag.
889 cache.envelope.clear();
890 cache.etag = None;
891 let status = match &reason {
892 FactsRejection::NotApplicable { applies_to } => CloudFactsState::NotApplicable {
893 applies_to: applies_to.clone(),
894 },
895 _ => CloudFactsState::Rejected {
896 reason: reason.to_string(),
897 at: now_unix(),
898 },
899 };
900 if !overlay::publish_with(
901 &ticket,
902 None,
903 state_status(status, None, &cache.url),
904 || {
905 if let Some(path) = &path {
906 save_cache(path, &cache);
907 }
908 },
909 ) {
910 return Err(RefreshError::Superseded);
911 }
912 Err(RefreshError::Rejected(reason))
913 }
914 }
915 }
916
917 pub fn spawn_background_refresh(
918 settings: Settings,
919 on_update: Option<Arc<dyn Fn() + Send + Sync>>,
920 ) {
921 let Ok(admitted) = ticket(&settings, codewhale_config::cloud_facts::TRUSTED_KEYS) else {
922 return;
923 };
924 if settings.local_path.is_none() && fetch_suppressed() {
925 return;
926 }
927 tokio::spawn(async move {
928 let before = overlay::snapshot().generation;
929 let outcome = refresh_using(
930 &settings,
931 false,
932 codewhale_config::cloud_facts::TRUSTED_KEYS,
933 Some(admitted),
934 fetch_suppressed(),
935 fetch,
936 )
937 .await;
938 tracing::debug!(target: "cloud_facts", ?outcome, "cloud facts refresh settled");
939 if overlay::snapshot().generation != before
940 && let Some(hook) = on_update
941 {
942 hook();
943 }
944 });
945 }
946
947 #[must_use]
948 pub fn status() -> CloudFactsStatus {
949 overlay::status()
950 }
951
952 #[cfg(test)]
953 mod tests;
954
954 lines RUST