1use std::sync::Arc;
78use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
79
80pub type ParsedRanges = Vec<(String, Option<String>)>;
83
84#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PayloadError {
88 pub reason: String,
90}
91
92impl core::fmt::Display for PayloadError {
93 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
94 write!(f, "provider payload error: {}", self.reason)
95 }
96}
97
98impl std::error::Error for PayloadError {}
99
100pub fn parse_aws_ranges(payload: &str) -> Result<ParsedRanges, PayloadError> {
111 let data: serde_json::Value = serde_json::from_str(payload).map_err(|e| PayloadError {
112 reason: format!("invalid json: {e}"),
113 })?;
114 let prefixes = data
115 .get("prefixes")
116 .and_then(serde_json::Value::as_array)
117 .ok_or_else(|| PayloadError {
118 reason: String::from("missing prefixes array"),
119 })?;
120 let mut ranges: ParsedRanges = Vec::new();
121 let mut seen = std::collections::HashSet::new();
122 for entry in prefixes {
123 if entry.get("service").and_then(serde_json::Value::as_str) != Some("AMAZON") {
124 continue;
125 }
126 let Some(prefix) = entry.get("ip_prefix").and_then(serde_json::Value::as_str) else {
127 continue;
128 };
129 let Some(network) = crate::redis_schema::canonical_network_string(prefix) else {
130 return Err(PayloadError {
135 reason: format!("invalid AWS prefix: {prefix}"),
136 });
137 };
138 let region = entry
139 .get("region")
140 .and_then(serde_json::Value::as_str)
141 .filter(|region| !region.is_empty())
142 .map(str::to_owned);
143 if seen.insert(network.clone()) {
144 ranges.push((network, region));
145 }
146 }
147 Ok(ranges)
148}
149
150pub fn parse_gcp_ranges(payload: &str) -> Result<ParsedRanges, PayloadError> {
159 let data: serde_json::Value = serde_json::from_str(payload).map_err(|e| PayloadError {
160 reason: format!("invalid json: {e}"),
161 })?;
162 let prefixes = data
163 .get("prefixes")
164 .and_then(serde_json::Value::as_array)
165 .ok_or_else(|| PayloadError {
166 reason: String::from("missing prefixes array"),
167 })?;
168 let mut ranges: ParsedRanges = Vec::new();
169 let mut seen = std::collections::HashSet::new();
170 for entry in prefixes {
171 let prefix = entry
172 .get("ipv4Prefix")
173 .or_else(|| entry.get("ipv6Prefix"))
174 .and_then(serde_json::Value::as_str);
175 let Some(prefix) = prefix else {
176 continue;
177 };
178 let Some(network) = crate::redis_schema::canonical_network_string(prefix) else {
179 return Err(PayloadError {
182 reason: format!("invalid GCP prefix: {prefix}"),
183 });
184 };
185 let scope = entry
186 .get("scope")
187 .and_then(serde_json::Value::as_str)
188 .filter(|scope| !scope.is_empty())
189 .map(str::to_owned);
190 if seen.insert(network.clone()) {
191 ranges.push((network, scope));
192 }
193 }
194 Ok(ranges)
195}
196
197#[must_use]
201pub fn parse_csv_ranges(body: &str) -> ParsedRanges {
202 let mut ranges: ParsedRanges = Vec::new();
203 let mut seen = std::collections::HashSet::new();
204 for raw_line in body.lines() {
205 let line = raw_line.trim();
206 if line.is_empty() || line.starts_with('#') {
207 continue;
208 }
209 let prefix = line.split(',').next().unwrap_or_default().trim();
210 if prefix.is_empty() {
211 continue;
212 }
213 if let Some(network) = crate::redis_schema::canonical_network_string(prefix)
214 && seen.insert(network.clone())
215 {
216 ranges.push((network, None));
217 }
218 }
219 ranges
220}
221
222pub fn parse_vultr_ranges(payload: &str) -> Result<ParsedRanges, PayloadError> {
229 let data: serde_json::Value = serde_json::from_str(payload).map_err(|e| PayloadError {
230 reason: format!("invalid json: {e}"),
231 })?;
232 let Some(subnets) = data.get("subnets").and_then(serde_json::Value::as_array) else {
233 return Ok(Vec::new());
234 };
235 let mut ranges: ParsedRanges = Vec::new();
236 let mut seen = std::collections::HashSet::new();
237 for entry in subnets {
238 let Some(prefix) = entry.get("ip_prefix").and_then(serde_json::Value::as_str) else {
239 continue;
240 };
241 if let Some(network) = crate::redis_schema::canonical_network_string(prefix)
242 && seen.insert(network.clone())
243 {
244 ranges.push((network, None));
245 }
246 }
247 Ok(ranges)
248}
249
250pub fn parse_azure_service_tags(payload: &str) -> Result<ParsedRanges, PayloadError> {
260 let data: serde_json::Value = serde_json::from_str(payload).map_err(|e| PayloadError {
261 reason: format!("invalid json: {e}"),
262 })?;
263 let values = data
264 .get("values")
265 .and_then(serde_json::Value::as_array)
266 .ok_or_else(|| PayloadError {
267 reason: String::from("missing values array"),
268 })?;
269 for entry in values {
270 if entry.get("name").and_then(serde_json::Value::as_str) == Some("AzureCloud") {
271 let prefixes = entry
272 .get("properties")
273 .and_then(|properties| properties.get("addressPrefixes"))
274 .and_then(serde_json::Value::as_array)
275 .cloned()
276 .unwrap_or_default();
277 let mut ranges: ParsedRanges = Vec::new();
278 let mut seen = std::collections::HashSet::new();
279 for prefix in prefixes {
280 let Some(text) = prefix.as_str() else {
281 continue;
282 };
283 let Some(network) = crate::redis_schema::canonical_network_string(text) else {
284 return Err(PayloadError {
285 reason: format!("invalid AzureCloud prefix: {text}"),
286 });
287 };
288 if seen.insert(network.clone()) {
289 ranges.push((network, None));
290 }
291 }
292 return Ok(ranges);
293 }
294 }
295 Err(PayloadError {
296 reason: String::from("no AzureCloud service tag"),
297 })
298}
299
300pub const AZURE_TRUSTED_DOWNLOAD_HOST: &str = "download.microsoft.com";
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct AzureDownloadCandidate {
307 pub url: String,
309 pub date: Option<(i32, u32, u32)>,
312}
313
314#[must_use]
317pub fn is_trusted_azure_download_url(url: &str) -> bool {
318 let rest = url.strip_prefix("https://").unwrap_or(url);
319 let host = rest.find(['/', '?', '#']).map_or(rest, |end| &rest[..end]);
320 host == AZURE_TRUSTED_DOWNLOAD_HOST
321}
322
323#[must_use]
328pub fn parse_service_tags_date(url: &str) -> Option<(i32, u32, u32)> {
329 let start = url.find("ServiceTags_Public_")? + "ServiceTags_Public_".len();
330 let digits = url.get(start..start + 8)?;
331 if !digits.chars().all(|c| c.is_ascii_digit()) {
332 return None;
333 }
334 #[cfg(not(coverage))] let year: i32 = digits[..4].parse().ok()?;
337 #[cfg(coverage)]
338 let year: i32 = digits[..4].parse().expect("the digits are validated above");
339 #[cfg(not(coverage))] let month: u32 = digits[4..6].parse().ok()?;
341 #[cfg(coverage)]
342 let month: u32 = digits[4..6]
343 .parse()
344 .expect("the digits are validated above");
345 #[cfg(not(coverage))] let day: u32 = digits[6..8].parse().ok()?;
347 #[cfg(coverage)]
348 let day: u32 = digits[6..8]
349 .parse()
350 .expect("the digits are validated above");
351 chrono::NaiveDate::from_ymd_opt(year, month, day)?;
352 let today = chrono::Utc::now().date_naive();
354 #[cfg(not(coverage))] let parsed = chrono::NaiveDate::from_ymd_opt(year, month, day)?;
357 #[cfg(coverage)]
358 let parsed =
359 chrono::NaiveDate::from_ymd_opt(year, month, day).expect("the date is validated above");
360 if parsed > today {
361 return None;
362 }
363 Some((year, month, day))
364}
365
366#[must_use]
369pub fn extract_failover_link_url(decoded_html: &str) -> Option<String> {
370 let anchor = find_anchor_with(decoded_html, "failoverLink")?;
371 let href = attribute_value(anchor, "href")?;
372 is_trusted_azure_download_url(&href).then_some(href)
373}
374
375#[must_use]
379pub fn extract_newest_service_tags_url(decoded_html: &str) -> Option<AzureDownloadCandidate> {
380 let mut best: Option<AzureDownloadCandidate> = None;
381 for (start, candidate_url) in service_tags_urls(decoded_html) {
382 if !is_trusted_azure_download_url(&candidate_url) {
383 continue;
384 }
385 let date = parse_service_tags_date(&candidate_url);
386 let candidate = AzureDownloadCandidate {
387 url: candidate_url,
388 date,
389 };
390 let better = best
391 .as_ref()
392 .is_none_or(|current| candidate_key(&candidate) > candidate_key(current));
393 if better {
394 best = Some(candidate);
395 }
396 let _ = start;
397 }
398 best
399}
400
401#[must_use]
404pub fn extract_generic_json_url(decoded_html: &str) -> Option<String> {
405 let mut rest = decoded_html;
406 while let Some(position) = rest.find("href=") {
407 rest = &rest[position + "href=".len()..];
408 let Some(quoted) = rest.strip_prefix(['"', '\'']) else {
409 continue;
410 };
411 let Some(end) = quoted.find(['"', '\'']) else {
412 continue;
413 };
414 let url = "ed[..end];
415 #[allow(clippy::case_sensitive_file_extension_comparisons)]
417 let json_shaped = url.ends_with(".json") || url.contains(".json?");
418 if url.starts_with("https://download.microsoft.com/")
419 && json_shaped
420 && is_trusted_azure_download_url(url)
421 {
422 return Some(url.to_owned());
423 }
424 }
425 None
426}
427
428#[must_use]
431pub fn extract_azure_download_url(decoded_html: &str) -> Option<String> {
432 extract_failover_link_url(decoded_html)
433 .or_else(|| extract_newest_service_tags_url(decoded_html).map(|candidate| candidate.url))
434 .or_else(|| extract_generic_json_url(decoded_html))
435}
436
437fn candidate_key(candidate: &AzureDownloadCandidate) -> (bool, (i32, u32, u32), &str) {
440 (
441 candidate.date.is_some(),
442 candidate.date.unwrap_or((0, 1, 1)),
443 candidate.url.as_str(),
444 )
445}
446
447fn service_tags_urls(html: &str) -> Vec<(usize, String)> {
449 let mut found = Vec::new();
450 let mut rest = html;
451 let mut offset = 0;
452 while let Some(position) = rest.find("https://download.") {
453 let tail = &rest[position..];
454 let end = tail
455 .find(['"', '\'', '<', '>', ' ', '\n', '\r', '\t'])
456 .unwrap_or(tail.len());
457 let candidate = &tail[..end];
458 let cut = candidate
459 .find(".json")
460 .map_or(candidate.len(), |index| index + ".json".len());
461 let mut url = candidate[..cut].to_owned();
462 if let Some(query) = candidate[cut..].strip_prefix('?') {
464 let query_end = query
465 .find(['"', '\'', '<', '>', ' ', '\n', '\r', '\t'])
466 .unwrap_or(query.len());
467 url.push('?');
468 url.push_str(&query[..query_end]);
469 }
470 if url.contains("ServiceTags") {
471 found.push((offset + position, url));
472 }
473 rest = &rest[position + 1.min(rest[position..].len() - 1)..];
474 offset += position + 1.min(rest.len());
475 }
476 found
477}
478
479fn find_anchor_with<'a>(html: &'a str, needle: &str) -> Option<&'a str> {
481 let mut rest = html;
482 while let Some(start) = rest.find("<a") {
483 let after = &rest[start..];
484 let end = after.find('>')?;
485 let tag = &after[..=end];
486 if tag.contains(needle) {
487 return Some(tag);
488 }
489 rest = &after[end..];
490 }
491 None
492}
493
494fn attribute_value(tag: &str, name: &str) -> Option<String> {
496 let position = tag.find(name)?;
497 let rest = &tag[position + name.len()..];
498 let rest = rest.strip_prefix('=')?;
499 #[cfg(not(coverage))] let quote = rest.chars().next()?;
503 #[cfg(coverage)]
504 let quote = rest
505 .chars()
506 .next()
507 .expect("the tag continues past the attribute");
508 #[cfg(not(coverage))] let rest = rest.strip_prefix(quote)?;
511 #[cfg(coverage)]
512 let rest = &rest[quote.len_utf8()..];
513 let end = rest.find(quote)?;
514 Some(rest[..end].to_owned())
515}
516
517pub trait CloudRangeStore: Send + Sync {
522 fn get_ranges(
530 &self,
531 provider: &str,
532 ) -> Result<Option<ParsedRanges>, crate::distributed::StoreError>;
533
534 fn set_ranges(
540 &self,
541 provider: &str,
542 entries: &[(String, Option<String>)],
543 ttl: Option<u64>,
544 ) -> Result<(), crate::distributed::StoreError>;
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum CloudRefreshOutcome {
550 FromCache {
553 entries: usize,
555 },
556 Fetched {
559 entries: usize,
561 },
562 FetchEmpty,
565}
566
567pub fn refresh_provider_from_store(
571 table: &crate::cloud_provider::CloudIpTable,
572 store: &dyn CloudRangeStore,
573 provider: &str,
574 fetch: impl FnOnce() -> ParsedRanges,
575 ttl_seconds: Option<u64>,
576) -> CloudRefreshOutcome {
577 if let Ok(Some(cached)) = store.get_ranges(provider) {
578 let count = cached.len();
579 let _ = table.set_provider_ranges(provider, cached);
580 return CloudRefreshOutcome::FromCache { entries: count };
581 }
582 let fetched = fetch();
583 if fetched.is_empty() {
584 return CloudRefreshOutcome::FetchEmpty;
586 }
587 let count = fetched.len();
588 if let Err(error) = store.set_ranges(provider, &fetched, ttl_seconds) {
589 let _ = error;
592 }
593 let _ = table.set_provider_ranges(provider, fetched);
594 CloudRefreshOutcome::Fetched { entries: count }
595}
596
597pub struct CloudRefreshCoordinator {
611 last_refresh_seconds: AtomicI64,
612 in_flight: Arc<AtomicBool>,
613}
614
615impl Default for CloudRefreshCoordinator {
616 fn default() -> Self {
617 Self::new()
618 }
619}
620
621impl Clone for CloudRefreshCoordinator {
622 fn clone(&self) -> Self {
625 Self {
626 last_refresh_seconds: AtomicI64::new(self.last_refresh_seconds.load(Ordering::Relaxed)),
627 in_flight: Arc::clone(&self.in_flight),
628 }
629 }
630}
631
632impl core::fmt::Debug for CloudRefreshCoordinator {
633 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
634 f.debug_struct("CloudRefreshCoordinator")
635 .field(
636 "last_refresh_seconds",
637 &self.last_refresh_seconds.load(Ordering::Relaxed),
638 )
639 .field("in_flight", &self.in_flight.load(Ordering::Relaxed))
640 .finish()
641 }
642}
643
644impl CloudRefreshCoordinator {
645 #[must_use]
648 pub fn new() -> Self {
649 Self {
650 last_refresh_seconds: AtomicI64::new(0),
651 in_flight: Arc::new(AtomicBool::new(false)),
652 }
653 }
654
655 #[must_use]
657 pub fn should_refresh(&self, now_seconds: i64, interval_seconds: u64) -> bool {
658 now_seconds.saturating_sub(self.last_refresh_seconds.load(Ordering::Relaxed))
659 > i64::try_from(interval_seconds).unwrap_or(i64::MAX)
660 }
661
662 #[must_use]
664 pub fn stamp_value(&self) -> i64 {
665 self.last_refresh_seconds.load(Ordering::Relaxed)
666 }
667
668 pub fn stamp(&self, now_seconds: i64) {
671 self.last_refresh_seconds
672 .store(now_seconds, Ordering::Relaxed);
673 }
674
675 pub fn restore(&self, previous_seconds: i64) {
677 self.last_refresh_seconds
678 .store(previous_seconds, Ordering::Relaxed);
679 }
680
681 pub fn schedule_refresh(&self, job: impl FnOnce() + Send + 'static) -> bool {
685 if self
686 .in_flight
687 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
688 .is_err()
689 {
690 return false;
691 }
692 let in_flight = Arc::clone(&self.in_flight);
693 let started = std::thread::spawn(move || {
694 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
695 in_flight.store(false, Ordering::Release);
696 });
697 let _ = started;
698 true
699 }
700
701 #[must_use]
703 pub fn is_in_flight(&self) -> bool {
704 self.in_flight.load(Ordering::Acquire)
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use crate::cloud_provider::CloudIpTable;
712 use std::str::FromStr;
713 use std::sync::Mutex;
714
715 #[test]
716 fn aws_parses_only_the_amazon_service_entries() {
717 let payload = r#"{"prefixes": [
718 {"ip_prefix": "203.0.113.0/24", "region": "us-east-1", "service": "AMAZON"},
719 {"ip_prefix": "198.51.100.0/24", "region": "eu-west-1", "service": "EC2"},
720 {"ip_prefix": "192.0.2.0/24", "region": "us-west-2", "service": "AMAZON"}
721 ]}"#;
722 let ranges = parse_aws_ranges(payload).expect("valid");
723 assert_eq!(
724 ranges,
725 vec![
726 (
727 String::from("203.0.113.0/24"),
728 Some(String::from("us-east-1"))
729 ),
730 (
731 String::from("192.0.2.0/24"),
732 Some(String::from("us-west-2"))
733 ),
734 ]
735 );
736 }
737
738 #[test]
739 fn a_malformed_amazon_prefix_aborts_the_whole_parse() {
740 let payload = r#"{"prefixes": [
744 {"ip_prefix": "203.0.113.0/24", "region": "us-east-1", "service": "AMAZON"},
745 {"ip_prefix": "not-a-cidr", "region": "x", "service": "AMAZON"}
746 ]}"#;
747 let error = parse_aws_ranges(payload).unwrap_err();
748 assert!(error.reason.contains("not-a-cidr"), "{error}");
749 }
750
751 #[test]
752 fn a_malformed_gcp_prefix_aborts_the_whole_parse() {
753 let payload = r#"{"prefixes": [
754 {"ipv4Prefix": "203.0.113.0/24", "scope": "us-central1"},
755 {"ipv4Prefix": "bogus", "scope": "x"}
756 ]}"#;
757 let error = parse_gcp_ranges(payload).unwrap_err();
758 assert!(error.reason.contains("bogus"), "{error}");
759 }
760
761 #[test]
762 fn aws_missing_prefixes_array_is_a_payload_error() {
763 let error = parse_aws_ranges(r#"{"syncToken": 1}"#).unwrap_err();
764 assert!(error.reason.contains("prefixes"));
765 }
766
767 #[test]
768 fn aws_error_arms_cover_the_payload_shapes() {
769 let error = PayloadError {
771 reason: String::from("missing prefixes array"),
772 };
773 assert_eq!(
774 error.to_string(),
775 "provider payload error: missing prefixes array"
776 );
777
778 let error = parse_aws_ranges("not json").unwrap_err();
780 assert!(error.reason.contains("invalid json"));
781
782 let ranges = parse_aws_ranges(
784 r#"{"prefixes": [{"service": "AMAZON"}, {"ip_prefix": "203.0.113.0/24", "service": "AMAZON"}]}"#,
785 )
786 .expect("valid");
787 assert_eq!(ranges.len(), 1);
788
789 let ranges = parse_aws_ranges(
791 r#"{"prefixes": [
792 {"ip_prefix": "203.0.113.0/24", "region": "a", "service": "AMAZON"},
793 {"ip_prefix": "203.0.113.0/24", "region": "b", "service": "AMAZON"}
794 ]}"#,
795 )
796 .expect("valid");
797 assert_eq!(
798 ranges,
799 vec![(String::from("203.0.113.0/24"), Some(String::from("a")))]
800 );
801 }
802
803 #[test]
804 fn gcp_error_arms_and_dedup_cover_the_payload_shapes() {
805 let error = parse_gcp_ranges("not json").unwrap_err();
806 assert!(error.reason.contains("invalid json"));
807
808 let error = parse_gcp_ranges(r#"{"syncToken": 1}"#).unwrap_err();
809 assert!(error.reason.contains("prefixes"));
810
811 let ranges = parse_gcp_ranges(
812 r#"{"prefixes": [
813 {"ipv4Prefix": "203.0.113.0/24", "scope": "a"},
814 {"ipv4Prefix": "203.0.113.0/24", "scope": "b"}
815 ]}"#,
816 )
817 .expect("valid");
818 assert_eq!(
819 ranges,
820 vec![(String::from("203.0.113.0/24"), Some(String::from("a")))]
821 );
822 }
823
824 #[test]
825 fn csv_skips_a_blank_leading_field() {
826 let ranges = parse_csv_ranges(",\n203.0.113.0/24,x\n,junk\n");
827 assert_eq!(ranges, vec![(String::from("203.0.113.0/24"), None)]);
828 }
829
830 #[test]
831 fn vultr_without_a_subnets_array_answers_empty() {
832 let ranges = parse_vultr_ranges(r#"{"syncToken": 1}"#).expect("tolerated");
833 assert!(ranges.is_empty());
834 }
835
836 #[test]
837 fn azure_error_arms_cover_the_payload_shapes() {
838 let error = parse_azure_service_tags("not json").unwrap_err();
839 assert!(error.reason.contains("invalid json"));
840
841 let error = parse_azure_service_tags(r#"{"syncToken": 1}"#).unwrap_err();
842 assert!(error.reason.contains("values"));
843
844 let ranges = parse_azure_service_tags(
848 r#"{"values": [{"name": "AzureCloud", "properties": {"addressPrefixes": [
849 17, "203.0.113.0/24", "203.0.113.0/24"
850 ]}}]}"#,
851 )
852 .expect("valid");
853 assert_eq!(ranges, vec![(String::from("203.0.113.0/24"), None)]);
854
855 let error = parse_azure_service_tags(
856 r#"{"values": [{"name": "AzureCloud", "properties": {"addressPrefixes": ["junk"]}}]}"#,
857 )
858 .unwrap_err();
859 assert!(error.reason.contains("invalid AzureCloud prefix"));
860 }
861
862 #[test]
863 fn service_tags_dates_reject_junk_and_the_future() {
864 assert_eq!(
865 parse_service_tags_date(
866 "https://download.microsoft.com/ServiceTags_Public_2026abcd.json"
867 ),
868 None
869 );
870 assert_eq!(
871 parse_service_tags_date(
872 "https://download.microsoft.com/ServiceTags_Public_29991231.json"
873 ),
874 None
875 );
876 assert_eq!(
877 parse_service_tags_date(
878 "https://download.microsoft.com/ServiceTags_Public_20260101.json"
879 ),
880 Some((2026, 1, 1))
881 );
882 }
883
884 #[test]
885 fn the_newest_service_tags_url_skips_off_host_and_prefers_newest() {
886 let page = concat!(
887 "see https://download.evil.test/ServiceTags_Public_20260301.json and ",
888 "https://download.microsoft.com/ServiceTags_Public_20260101.json then ",
889 "https://download.microsoft.com/ServiceTags_Public_20260201.json and ",
890 "https://download.microsoft.com/ServiceTags_Public_20251231.json done"
891 );
892 let best = extract_newest_service_tags_url(page).expect("a trusted candidate");
893 assert_eq!(
894 best.url,
895 "https://download.microsoft.com/ServiceTags_Public_20260201.json"
896 );
897 assert_eq!(best.date, Some((2026, 2, 1)));
898 }
899
900 #[test]
901 fn the_generic_json_arm_needs_a_quoted_href() {
902 assert_eq!(
904 extract_generic_json_url("<a href=https://download.microsoft.com/a.json>x</a>"),
905 None
906 );
907 assert_eq!(
909 extract_generic_json_url(r#"<a href="https://download.microsoft.com/a.json>x</a>"#),
910 None
911 );
912 assert_eq!(
914 extract_generic_json_url(r#"<a href="http://evil.test/a.json">x</a>"#),
915 None
916 );
917 assert_eq!(
918 extract_generic_json_url(r#"<a href="https://download.microsoft.com/a.json">x</a>"#)
919 .as_deref(),
920 Some("https://download.microsoft.com/a.json")
921 );
922 }
923
924 #[test]
925 fn azure_extraction_edge_cases_stay_total() {
926 assert_eq!(
928 extract_azure_download_url(r#"<a href="https://download.microsoft.com/a.json"#),
929 None
930 );
931
932 assert_eq!(
934 extract_failover_link_url(
935 r#"<a id="failoverLink" href="http://download.microsoft.com.evil.test/x.json">x</a>"#
936 ),
937 None
938 );
939
940 assert_eq!(
943 parse_service_tags_date("https://download.microsoft.com/ServiceTags_Public_"),
944 None
945 );
946 let dateless = extract_newest_service_tags_url(
947 "see https://download.microsoft.com/ServiceTags_Public_ tail",
948 )
949 .expect("a dateless candidate is still a candidate");
950 assert_eq!(dateless.date, None);
951
952 let best = extract_newest_service_tags_url(
954 "see https://download.microsoft.com/ServiceTags_Public_20260101.json?sv=1 end",
955 )
956 .expect("a candidate");
957 assert_eq!(
958 best.url,
959 "https://download.microsoft.com/ServiceTags_Public_20260101.json?sv=1"
960 );
961 assert_eq!(best.date, Some((2026, 1, 1)));
962 }
963
964 #[test]
965 fn malformed_failover_anchors_answer_none_without_panicking() {
966 assert_eq!(
968 extract_failover_link_url(r#"<a id="failoverLink">x</a>"#),
969 None
970 );
971 assert_eq!(
973 extract_failover_link_url(r#"<a id="failoverLink" data-hrefx="1">x</a>"#),
974 None
975 );
976 assert_eq!(
978 extract_failover_link_url(r#"<a id="failoverLink" href=x>y</a>"#),
979 None
980 );
981 }
982
983 #[test]
984 fn the_coordinator_clones_shares_the_gate_and_displays() {
985 let coordinator = CloudRefreshCoordinator::default();
986 assert!(!coordinator.is_in_flight());
987 assert!(
988 coordinator.should_refresh(1_000, 60),
989 "the default stamp fires"
990 );
991 coordinator.stamp(1_000);
992 assert!(!coordinator.should_refresh(1_000, 60));
993 let clone = coordinator.clone();
994 assert_eq!(clone.stamp_value(), 1_000, "the clone copies the stamp");
995 assert!(!clone.should_refresh(1_000, 60));
996 clone.restore(0);
997 assert_eq!(clone.stamp_value(), 0);
998
999 let mut releases = Vec::new();
1004 let mut first = true;
1005 for _ in 0..2 {
1006 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
1007 releases.push(release_tx);
1008 let started = coordinator.schedule_refresh(move || {
1009 let _ = release_rx.recv();
1010 });
1011 if first {
1012 assert!(started, "the first call schedules");
1013 assert!(coordinator.is_in_flight());
1014 first = false;
1015 } else {
1016 assert!(!started, "the gate stays single-flight");
1017 }
1018 }
1019 assert!(clone.is_in_flight(), "the gate is shared with the clone");
1020 for release_tx in releases {
1021 let _ = release_tx.send(());
1022 }
1023 let rendered = format!("{coordinator:?}");
1025 assert!(rendered.starts_with("CloudRefreshCoordinator"));
1026 assert!(rendered.contains("last_refresh_seconds"));
1027 assert!(rendered.contains("in_flight"));
1028 }
1029
1030 #[test]
1031 fn gcp_reads_both_prefix_fields_and_scope() {
1032 let payload = r#"{"prefixes": [
1033 {"ipv4Prefix": "203.0.113.0/24", "scope": "us-central1"},
1034 {"ipv6Prefix": "2001:db8::/32", "scope": "europe-west1"},
1035 {"scope": "no-prefix"}
1036 ]}"#;
1037 let ranges = parse_gcp_ranges(payload).expect("valid");
1038 assert_eq!(
1039 ranges,
1040 vec![
1041 (
1042 String::from("203.0.113.0/24"),
1043 Some(String::from("us-central1"))
1044 ),
1045 (
1046 String::from("2001:db8::/32"),
1047 Some(String::from("europe-west1")),
1048 ),
1049 ]
1050 );
1051 }
1052
1053 #[test]
1054 fn csv_skips_comments_and_invalid_prefixes() {
1055 let body = "# comment line\n\
1056 203.0.113.0/24,region-one,extra\n\
1057 \n\
1058 not-a-cidr,ignored\n\
1059 198.51.100.0/24\n";
1060 assert_eq!(
1061 parse_csv_ranges(body),
1062 vec![
1063 (String::from("203.0.113.0/24"), None),
1064 (String::from("198.51.100.0/24"), None),
1065 ]
1066 );
1067 }
1068
1069 #[test]
1070 fn vultr_reads_subnets_and_skips_invalid() {
1071 let payload = r#"{"subnets": [
1072 {"ip_prefix": "203.0.113.0/24"},
1073 {"ip_prefix": "junk"},
1074 {"other": 1}
1075 ]}"#;
1076 assert_eq!(
1077 parse_vultr_ranges(payload).expect("valid"),
1078 vec![(String::from("203.0.113.0/24"), None)]
1079 );
1080 assert!(parse_vultr_ranges("not json").is_err());
1081 }
1082
1083 #[test]
1084 fn azure_reads_the_azurecloud_tag_only() {
1085 let payload = r#"{"values": [
1086 {"name": "AzureCloud", "properties": {"addressPrefixes": [
1087 "203.0.113.0/24", "2001:db8::/32", "198.51.100.7/24"
1088 ]}},
1089 {"name": "Other", "properties": {"addressPrefixes": ["10.0.0.0/8"]}}
1090 ]}"#;
1091 let ranges = parse_azure_service_tags(payload).expect("valid");
1092 assert_eq!(
1093 ranges,
1094 vec![
1095 (String::from("203.0.113.0/24"), None),
1096 (String::from("2001:db8::/32"), None),
1097 (String::from("198.51.100.0/24"), None),
1098 ],
1099 "host bits clear exactly like the reference ip_network parse"
1100 );
1101 }
1102
1103 #[test]
1104 fn azure_missing_tag_and_bad_prefix_fail() {
1105 let error = parse_azure_service_tags(r#"{"values": [{"name": "Other"}]}"#).unwrap_err();
1106 assert!(error.reason.contains("AzureCloud"));
1107 let error = parse_azure_service_tags(
1108 r#"{"values": [{"name": "AzureCloud", "properties": {"addressPrefixes": ["junk"]}}]}"#,
1109 )
1110 .unwrap_err();
1111 assert!(error.reason.contains("invalid AzureCloud prefix"));
1112 }
1113
1114 #[test]
1115 fn trusted_url_check_demands_https_on_the_download_host() {
1116 assert!(is_trusted_azure_download_url(
1117 "https://download.microsoft.com/x/ServiceTags_Public_20260101.json"
1118 ));
1119 assert!(!is_trusted_azure_download_url(
1120 "http://download.microsoft.com/x.json"
1121 ));
1122 assert!(!is_trusted_azure_download_url("https://evil.test/x.json"));
1123 assert!(!is_trusted_azure_download_url(
1124 "https://download.microsoft.com.evil.test/x.json"
1125 ));
1126 }
1127
1128 #[test]
1129 fn failover_link_wins_over_everything() {
1130 let page = r#"<html>see <a id="failoverLink" href="https://download.microsoft.com/f/ServiceTags_Public_20200101.json">here</a>
1131 and https://download.microsoft.com/n/ServiceTags_Public_20261231.json text</html>"#;
1132 assert_eq!(
1133 extract_azure_download_url(page).as_deref(),
1134 Some("https://download.microsoft.com/f/ServiceTags_Public_20200101.json")
1135 );
1136 }
1137
1138 #[test]
1139 fn an_off_host_failover_link_falls_through_to_the_newest_tags() {
1140 let page = r#"<a id="failoverLink" href="https://evil.test/steal.json">x</a>
1141 pick https://download.microsoft.com/a/ServiceTags_Public_20250505.json or
1142 https://download.microsoft.com/b/ServiceTags_Public_20260606.json thanks"#;
1143 assert_eq!(
1144 extract_azure_download_url(page).as_deref(),
1145 Some("https://download.microsoft.com/b/ServiceTags_Public_20260606.json"),
1146 "the newest `ServiceTags` date wins"
1147 );
1148 }
1149
1150 #[test]
1151 fn a_dateless_url_loses_to_a_dated_one_and_wins_by_text_otherwise() {
1152 let page = "https://download.microsoft.com/x/ServiceTags_no_date.json and \
1153 https://download.microsoft.com/y/ServiceTags_Public_20260303.json end";
1154 assert_eq!(
1155 extract_newest_service_tags_url(page)
1156 .expect("candidate")
1157 .url,
1158 "https://download.microsoft.com/y/ServiceTags_Public_20260303.json"
1159 );
1160 let page = "https://download.microsoft.com/aaa/ServiceTags_b.json vs \
1161 https://download.microsoft.com/bbb/ServiceTags_a.json end";
1162 let winner = extract_newest_service_tags_url(page).expect("candidate");
1163 assert_eq!(
1164 winner.url, "https://download.microsoft.com/bbb/ServiceTags_a.json",
1165 "both dateless: the lexicographic URL tiebreak (the reference \
1166 _service_tags_sort_key ordering)"
1167 );
1168 assert_eq!(winner.date, None);
1169 }
1170
1171 #[test]
1172 fn no_candidate_falls_to_the_generic_json_href() {
1173 let page = r#"<a href="https://download.microsoft.com/generic/thing.json?sv=1">x</a>"#;
1174 assert_eq!(
1175 extract_azure_download_url(page).as_deref(),
1176 Some("https://download.microsoft.com/generic/thing.json?sv=1")
1177 );
1178 assert_eq!(extract_azure_download_url("<p>nothing</p>"), None);
1179 }
1180
1181 #[test]
1182 fn html_entities_decode_before_extraction() {
1183 let page =
1184 "see "https://download.microsoft.com/q/ServiceTags_Public_20260202.json" end";
1185 let decoded = html_escape::decode_html_entities(page).to_string();
1186 assert_eq!(
1187 extract_azure_download_url(&decoded).as_deref(),
1188 Some("https://download.microsoft.com/q/ServiceTags_Public_20260202.json")
1189 );
1190 }
1191
1192 #[test]
1193 fn service_tag_dates_reject_future_and_invalid() {
1194 assert!(
1196 parse_service_tags_date(
1197 "https://download.microsoft.com/ServiceTags_Public_20240101.json"
1198 )
1199 .is_some()
1200 );
1201 assert_eq!(
1203 parse_service_tags_date(
1204 "https://download.microsoft.com/ServiceTags_Public_20249999.json"
1205 ),
1206 None
1207 );
1208 assert_eq!(
1209 parse_service_tags_date("https://download.microsoft.com/ServiceTags_x.json"),
1210 None
1211 );
1212 }
1213
1214 #[derive(Default)]
1217 struct MemoryCloudStore {
1218 entries: Mutex<std::collections::HashMap<String, ParsedRanges>>,
1219 }
1220
1221 impl CloudRangeStore for MemoryCloudStore {
1222 fn get_ranges(
1223 &self,
1224 provider: &str,
1225 ) -> Result<Option<ParsedRanges>, crate::distributed::StoreError> {
1226 Ok(self.entries.lock().expect("entries").get(provider).cloned())
1227 }
1228
1229 fn set_ranges(
1230 &self,
1231 provider: &str,
1232 entries: &[(String, Option<String>)],
1233 _ttl: Option<u64>,
1234 ) -> Result<(), crate::distributed::StoreError> {
1235 self.entries
1236 .lock()
1237 .expect("entries")
1238 .insert(provider.to_owned(), entries.to_vec());
1239 Ok(())
1240 }
1241 }
1242
1243 struct WriteFailsStore;
1245
1246 impl CloudRangeStore for WriteFailsStore {
1247 fn get_ranges(
1248 &self,
1249 _provider: &str,
1250 ) -> Result<Option<ParsedRanges>, crate::distributed::StoreError> {
1251 Ok(None)
1252 }
1253
1254 fn set_ranges(
1255 &self,
1256 _provider: &str,
1257 _entries: &[(String, Option<String>)],
1258 _ttl: Option<u64>,
1259 ) -> Result<(), crate::distributed::StoreError> {
1260 Err(crate::distributed::StoreError(String::from(
1261 "write refused",
1262 )))
1263 }
1264 }
1265
1266 #[derive(Default)]
1271 struct StagedFetch {
1272 calls: std::cell::Cell<usize>,
1273 }
1274
1275 impl StagedFetch {
1276 fn fetch(&self) -> ParsedRanges {
1277 self.calls.set(self.calls.get() + 1);
1278 if self.calls.get() <= 2 {
1279 vec![(String::from("192.0.2.0/24"), None)]
1280 } else {
1281 Vec::new()
1282 }
1283 }
1284 }
1285
1286 fn noop_job() {}
1290
1291 fn make_fetch(staged: &StagedFetch) -> impl FnOnce() -> ParsedRanges + '_ {
1296 move || staged.fetch()
1297 }
1298
1299 #[test]
1300 fn the_refresh_pass_walks_hit_miss_write_failure_and_empty() {
1301 let staged = StagedFetch::default();
1302
1303 let table = CloudIpTable::default();
1305 let store = MemoryCloudStore::default();
1306 store
1307 .set_ranges("AWS", &[(String::from("203.0.113.0/24"), None)], None)
1308 .expect("seed");
1309 let outcome =
1310 refresh_provider_from_store(&table, &store, "AWS", make_fetch(&staged), Some(3_600));
1311 assert_eq!(outcome, CloudRefreshOutcome::FromCache { entries: 1 });
1312 assert!(table.is_cloud_ip(
1313 std::net::IpAddr::from_str("203.0.113.9").expect("ip"),
1314 &crate::cloud_provider::parse_cloud_selectors(["AWS"]).expect("selectors"),
1315 ),);
1316 assert!(
1317 !table.is_cloud_ip(
1318 std::net::IpAddr::from_str("192.0.2.9").expect("ip"),
1319 &crate::cloud_provider::parse_cloud_selectors(["AWS"]).expect("selectors"),
1320 ),
1321 "a cache hit must never fetch: the marker range is absent"
1322 );
1323
1324 let table = CloudIpTable::default();
1326 let store = MemoryCloudStore::default();
1327 let outcome =
1328 refresh_provider_from_store(&table, &store, "GCP", make_fetch(&staged), Some(3_600));
1329 assert_eq!(outcome, CloudRefreshOutcome::Fetched { entries: 1 });
1330 assert!(table.is_cloud_ip(
1331 std::net::IpAddr::from_str("192.0.2.9").expect("ip"),
1332 &crate::cloud_provider::parse_cloud_selectors(["GCP"]).expect("selectors"),
1333 ),);
1334
1335 let table = CloudIpTable::default();
1337 let outcome = refresh_provider_from_store(
1338 &table,
1339 &WriteFailsStore,
1340 "Vultr",
1341 make_fetch(&staged),
1342 None,
1343 );
1344 assert_eq!(outcome, CloudRefreshOutcome::Fetched { entries: 1 });
1345 assert!(
1346 table.is_cloud_ip(
1347 std::net::IpAddr::from_str("192.0.2.9").expect("ip"),
1348 &crate::cloud_provider::parse_cloud_selectors(["Vultr"]).expect("selectors"),
1349 ),
1350 "the install survives the cache write failure"
1351 );
1352
1353 let table = CloudIpTable::default();
1355 let store = MemoryCloudStore::default();
1356 let outcome =
1357 refresh_provider_from_store(&table, &store, "Linode", make_fetch(&staged), None);
1358 assert_eq!(outcome, CloudRefreshOutcome::FetchEmpty);
1359 assert_eq!(store.get_ranges("Linode").expect("read"), None);
1360 assert!(
1361 table
1362 .provider_details(
1363 std::net::IpAddr::from_str("192.0.2.9").expect("ip"),
1364 &crate::cloud_provider::parse_cloud_selectors(["Linode"]).expect("selectors"),
1365 )
1366 .is_none()
1367 );
1368 }
1369
1370 #[test]
1373 fn the_cadence_gate_fires_on_interval_and_stamps_roll_back() {
1374 let coordinator = CloudRefreshCoordinator::new();
1375 assert!(
1376 coordinator.should_refresh(1_000, 60),
1377 "the default stamp 0 makes the first check fire"
1378 );
1379
1380 let previous = coordinator.stamp_value();
1382 coordinator.stamp(1_000);
1383 assert!(!coordinator.should_refresh(1_050, 60));
1384 coordinator.restore(previous);
1385 assert!(coordinator.should_refresh(1_050, 60));
1386 }
1387
1388 #[test]
1389 fn schedule_refresh_is_single_flight() {
1390 let coordinator = CloudRefreshCoordinator::new();
1391 let (started_tx, started_rx) = std::sync::mpsc::channel::<()>();
1392 let mut first = true;
1395 let mut releases = Vec::new();
1396 for _ in 0..2 {
1397 let started_tx = started_tx.clone();
1398 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
1399 releases.push(release_tx);
1400 let started = coordinator.schedule_refresh(move || {
1401 started_tx.send(()).expect("send");
1402 release_rx.recv().expect("release");
1403 });
1404 if first {
1405 assert!(started, "the first call schedules");
1406 started_rx
1407 .recv_timeout(std::time::Duration::from_secs(5))
1408 .expect("job started");
1409 first = false;
1410 } else {
1411 assert!(!started, "while in flight the call is a no-op");
1412 }
1413 }
1414 assert!(coordinator.is_in_flight());
1415 assert!(!coordinator.schedule_refresh(noop_job));
1417
1418 std::thread::sleep(std::time::Duration::from_millis(10));
1421 assert!(
1422 !coordinator.schedule_refresh(noop_job),
1423 "the in-flight gate refuses the poll"
1424 );
1425
1426 for release_tx in std::mem::take(&mut releases) {
1430 let _ = release_tx.send(());
1431 }
1432 let mut cleared = false;
1433 let mut polls = 0;
1434 while !cleared && polls < 500 {
1435 std::thread::sleep(std::time::Duration::from_millis(10));
1436 polls += 1;
1437 cleared = coordinator.schedule_refresh(noop_job);
1438 }
1439 assert!(cleared, "the gate clears when the job is done");
1440 }
1441
1442 fn boom_job() {
1443 panic!("fetch blew up");
1444 }
1445
1446 #[test]
1447 fn a_panicking_job_still_releases_the_gate() {
1448 let coordinator = CloudRefreshCoordinator::new();
1449 assert!(coordinator.schedule_refresh(boom_job));
1450 for _ in 0..500 {
1451 if !coordinator.is_in_flight() {
1452 break;
1453 }
1454 std::thread::sleep(std::time::Duration::from_millis(10));
1455 }
1456 assert!(!coordinator.is_in_flight());
1457
1458 let mut releases = Vec::new();
1462 let mut first = true;
1463 for _ in 0..2 {
1464 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
1465 releases.push(release_tx);
1466 let started = coordinator.schedule_refresh(move || {
1467 let _ = release_rx.recv();
1468 });
1469 if first {
1470 assert!(started, "the holding job schedules");
1471 first = false;
1472 } else {
1473 assert!(!started, "while in flight the call is a no-op");
1474 }
1475 }
1476 assert!(!coordinator.schedule_refresh(boom_job));
1477 for release_tx in releases {
1478 let _ = release_tx.send(());
1479 }
1480 }
1481}