Skip to main content

purple_ssh/providers/
mod.rs

1pub mod aws;
2pub mod aws_profile;
3pub mod aws_ssm;
4pub mod aws_sts;
5pub mod azure;
6pub mod config;
7mod digitalocean;
8pub mod gcp;
9mod hetzner;
10mod i3d;
11pub mod kind;
12mod leaseweb;
13mod linode;
14mod netbox;
15pub mod oracle;
16pub mod ovh;
17mod proxmox;
18pub mod scaleway;
19pub mod sync;
20mod tailscale;
21mod teleport;
22mod transip;
23mod upcloud;
24mod vultr;
25
26pub use kind::ProviderKind;
27
28use std::sync::atomic::{AtomicBool, Ordering};
29
30use log::{debug, error, warn};
31use thiserror::Error;
32
33/// A host discovered from a cloud provider API.
34#[derive(Debug, Clone, Default)]
35pub struct ProviderHost {
36    /// Provider-assigned server ID.
37    pub server_id: String,
38    /// Server name/label.
39    pub name: String,
40    /// Public IP address (IPv4 or IPv6) or a hostname the transport resolves.
41    pub ip: String,
42    /// Provider tags/labels.
43    pub tags: Vec<String>,
44    /// Provider metadata (region, plan, etc.) as key-value pairs.
45    pub metadata: Vec<(String, String)>,
46    /// SSH port when the provider knows it differs from 22.
47    pub port: Option<u16>,
48    /// Extra ssh_config directives the provider manages on this host, such as
49    /// a `ProxyCommand`. Written on add and kept current on later syncs.
50    pub directives: Vec<(String, String)>,
51    /// Directives the provider once wrote here and now wants gone.
52    pub retract_directives: Vec<RetractDirective>,
53}
54
55/// A directive the provider once wrote on a host and now withdraws.
56///
57/// A key the same host also lists in `directives` is written rather than
58/// withdrawn: the provider wants that value, so the rule is skipped.
59///
60/// The provider decides whether the value on disk is one of its own, because
61/// only it knows the shape it writes. A transport's command usually follows
62/// the vendor's published line, so its opening is exactly what a user who set
63/// the same thing up by hand would have typed; recognizing one by its opening
64/// would delete the other. A value the provider does not claim is left alone,
65/// so the worst case is a stale line the user can see and delete, never a line
66/// silently removed.
67#[derive(Debug, Clone)]
68pub struct RetractDirective {
69    /// The directive keyword, e.g. `ProxyCommand`.
70    pub key: String,
71    /// Whether `value` is one this provider wrote. `context` carries whatever
72    /// the decision needs, which for AWS is the region. A plain function
73    /// pointer rather than a closure, so `ProviderHost` stays `Clone`.
74    pub owns: fn(value: &str, context: &str) -> bool,
75    pub context: String,
76}
77
78impl RetractDirective {
79    /// Whether this rule claims the value currently on the host.
80    pub fn claims(&self, value: &str) -> bool {
81        (self.owns)(value, &self.context)
82    }
83}
84
85impl ProviderHost {
86    /// Create a ProviderHost with no metadata.
87    #[allow(dead_code)]
88    pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
89        Self {
90            server_id,
91            name,
92            ip,
93            tags,
94            ..Default::default()
95        }
96    }
97}
98
99/// Errors from provider API calls.
100#[derive(Debug, Error)]
101pub enum ProviderError {
102    #[error("HTTP error: {0}")]
103    Http(String),
104    #[error("Failed to parse response: {0}")]
105    Parse(String),
106    #[error("Authentication failed. Check your API token.")]
107    AuthFailed,
108    #[error("Rate limited. Try again in a moment.")]
109    RateLimited,
110    #[error("{0}")]
111    Execute(String),
112    #[error("Cancelled.")]
113    Cancelled,
114    /// Some hosts were fetched but others failed. The caller should use the
115    /// hosts but suppress destructive operations like --remove.
116    #[error("Partial result: {failures} of {total} failed")]
117    PartialResult {
118        hosts: Vec<ProviderHost>,
119        failures: usize,
120        total: usize,
121    },
122}
123
124/// Trait implemented by each cloud provider.
125///
126/// The trait deliberately stays narrow: every provider implements
127/// `fetch_hosts_cancellable` itself rather than overriding a uniform
128/// fetch loop. Variation is too wide for a single shape. Linode and
129/// DigitalOcean use `?page=N&per_page=N`; GCP uses `?maxResults=N`
130/// plus a `pageToken` cursor across an aggregated multi-zone listing;
131/// Azure paginates across multiple subscriptions; AWS uses SigV4 plus
132/// the EC2 query API; Tailscale mixes Basic and Bearer auth depending
133/// on key prefix; Oracle signs each request with a per-tenancy RSA
134/// key.
135///
136/// What is shared lives in this module as free helpers so every
137/// provider opts in to the parts that apply:
138///   - `bearer_auth`: `Authorization: Bearer <token>` formatter.
139///   - `ProviderMetadata`: typed builder for `ProviderHost::metadata`.
140///   - `paginate`: cancellable page-walk with `MAX_PAGES` guard.
141///   - `http_agent` / `http_agent_insecure`: ureq agent setup.
142///   - `map_ureq_error`: HTTP error to `ProviderError` mapping
143///     (401 to `AuthFailed`, 429 to `RateLimited`, etc).
144///   - `strip_cidr`, `percent_encode`, `epoch_to_date`: string and
145///     URL helpers.
146///
147/// Adding a new provider: implement `fetch_hosts_cancellable` and
148/// reach for the helpers above instead of reimplementing them. Do
149/// not invent provider-specific copies of these primitives; that is
150/// where past parity bugs have hidden.
151pub trait Provider {
152    /// Full provider name (e.g. "digitalocean").
153    fn name(&self) -> &str;
154    /// Short label for aliases (e.g. "do").
155    fn short_label(&self) -> &str;
156    /// Fetch hosts with cancellation support. `env` carries the resolved
157    /// process environment (home directory, credential env vars) so the few
158    /// providers that read AWS credentials or expand `~` in key paths take
159    /// them from the injected snapshot instead of ambient `std::env` /
160    /// `dirs::home_dir`. Most providers ignore it.
161    #[allow(dead_code)]
162    fn fetch_hosts_cancellable(
163        &self,
164        token: &str,
165        cancel: &AtomicBool,
166        env: &crate::runtime::env::Env,
167    ) -> Result<Vec<ProviderHost>, ProviderError>;
168    /// Fetch all servers from the provider API.
169    #[allow(dead_code)]
170    fn fetch_hosts(
171        &self,
172        token: &str,
173        env: &crate::runtime::env::Env,
174    ) -> Result<Vec<ProviderHost>, ProviderError> {
175        self.fetch_hosts_cancellable(token, &AtomicBool::new(false), env)
176    }
177    /// Fetch hosts with progress reporting. Default delegates to fetch_hosts_cancellable.
178    #[allow(dead_code)]
179    fn fetch_hosts_with_progress(
180        &self,
181        token: &str,
182        cancel: &AtomicBool,
183        env: &crate::runtime::env::Env,
184        _progress: &dyn Fn(&str),
185    ) -> Result<Vec<ProviderHost>, ProviderError> {
186        self.fetch_hosts_cancellable(token, cancel, env)
187    }
188}
189
190/// Parse a comma-separated provider config field into a list of trimmed,
191/// non-empty entries. Used for regions/zones/subscriptions.
192fn parse_csv(s: &str) -> Vec<String> {
193    s.split(',')
194        .map(|s| s.trim().to_string())
195        .filter(|s| !s.is_empty())
196        .collect()
197}
198
199/// Format an `Authorization: Bearer <token>` header value. Centralised so
200/// the literal lives in one place across the 9-plus providers that use
201/// bearer auth; a typo in any of them would silently break sync.
202pub(crate) fn bearer_auth(token: &str) -> String {
203    format!("Bearer {token}")
204}
205
206/// Builder for `ProviderHost::metadata`. Replaces the
207/// `metadata.push(("region".to_string(), value.clone()))` boilerplate
208/// that repeated across every provider with a typed builder so callers
209/// only spell the value once and never clone strings by hand.
210///
211/// Conventions: keys are spelled as `&'static str` literals on each
212/// call so a typo is a compile error rather than a silent runtime
213/// mismatch downstream. The canonical key set (`region`, `status`,
214/// `plan`, `image`, `os`, `location`, `type`, `zone`, `size`, `instance`,
215/// `project`, `compartment`, `tenancy`) lives in the trait docs.
216#[derive(Debug, Default)]
217pub(crate) struct ProviderMetadata(Vec<(String, String)>);
218
219impl ProviderMetadata {
220    pub(crate) fn new() -> Self {
221        Self(Vec::new())
222    }
223
224    /// Append `(key, value)` and return the builder for chaining.
225    /// `value: impl Into<String>` accepts both `String` and `&str`, so
226    /// callers do not have to `.to_string()` their fields by hand.
227    pub(crate) fn push(&mut self, key: &'static str, value: impl Into<String>) -> &mut Self {
228        self.0.push((key.to_string(), value.into()));
229        self
230    }
231
232    /// Append `(key, value)` only when `value` is `Some`. Saves the
233    /// repeated `if let Some(v) = ... { metadata.push(...) }` wrapper.
234    pub(crate) fn push_opt<S: Into<String>>(
235        &mut self,
236        key: &'static str,
237        value: Option<S>,
238    ) -> &mut Self {
239        if let Some(v) = value {
240            self.0.push((key.to_string(), v.into()));
241        }
242        self
243    }
244
245    /// Consume the builder, yielding the canonical `Vec<(String,
246    /// String)>` shape that `ProviderHost::metadata` expects.
247    pub(crate) fn finish(self) -> Vec<(String, String)> {
248        self.0
249    }
250}
251
252/// Factory for a provider implementation from an optional config section.
253/// `None` yields a default-constructed instance; `Some(section)` wires the
254/// section's fields into the provider struct.
255type ProviderBuild = fn(Option<&config::ProviderSection>) -> Box<dyn Provider>;
256
257/// Static registry entry describing one provider. Adding a provider means
258/// adding exactly one `ProviderDescriptor` to `PROVIDERS` below.
259pub struct ProviderDescriptor {
260    /// Slug used in config files and aliases.
261    pub name: &'static str,
262    /// Human-readable name shown in the UI.
263    pub display: &'static str,
264    /// Builder. Must not allocate or fail.
265    pub build: ProviderBuild,
266}
267
268/// Single source of truth for the provider registry. Adding a new provider
269/// means one entry here plus the provider module itself.
270pub const PROVIDERS: &[ProviderDescriptor] = &[
271    ProviderDescriptor {
272        name: "digitalocean",
273        display: "DigitalOcean",
274        build: |_| Box::new(digitalocean::DigitalOcean),
275    },
276    ProviderDescriptor {
277        name: "vultr",
278        display: "Vultr",
279        build: |_| Box::new(vultr::Vultr),
280    },
281    ProviderDescriptor {
282        name: "linode",
283        display: "Linode",
284        build: |_| Box::new(linode::Linode),
285    },
286    ProviderDescriptor {
287        name: "hetzner",
288        display: "Hetzner",
289        build: |_| Box::new(hetzner::Hetzner),
290    },
291    ProviderDescriptor {
292        name: "upcloud",
293        display: "UpCloud",
294        build: |_| Box::new(upcloud::UpCloud),
295    },
296    ProviderDescriptor {
297        name: "proxmox",
298        display: "Proxmox VE",
299        build: |section| {
300            let s = section.cloned().unwrap_or_default();
301            Box::new(proxmox::Proxmox {
302                base_url: s.url,
303                verify_tls: s.verify_tls,
304            })
305        },
306    },
307    ProviderDescriptor {
308        name: "aws",
309        display: "AWS EC2",
310        build: |section| {
311            let s = section.cloned().unwrap_or_default();
312            Box::new(aws::Aws {
313                regions: parse_csv(&s.regions),
314                profile: s.profile,
315                ssm: s.ssm,
316            })
317        },
318    },
319    ProviderDescriptor {
320        name: "scaleway",
321        display: "Scaleway",
322        build: |section| {
323            let s = section.cloned().unwrap_or_default();
324            Box::new(scaleway::Scaleway {
325                zones: parse_csv(&s.regions),
326            })
327        },
328    },
329    ProviderDescriptor {
330        name: "gcp",
331        display: "GCP",
332        build: |section| {
333            let s = section.cloned().unwrap_or_default();
334            Box::new(gcp::Gcp {
335                zones: parse_csv(&s.regions),
336                project: s.project,
337            })
338        },
339    },
340    ProviderDescriptor {
341        name: "azure",
342        display: "Azure",
343        build: |section| {
344            let s = section.cloned().unwrap_or_default();
345            Box::new(azure::Azure {
346                subscriptions: parse_csv(&s.regions),
347            })
348        },
349    },
350    ProviderDescriptor {
351        name: "tailscale",
352        display: "Tailscale",
353        build: |_| Box::new(tailscale::Tailscale),
354    },
355    ProviderDescriptor {
356        name: "oracle",
357        display: "Oracle Cloud",
358        build: |section| {
359            let s = section.cloned().unwrap_or_default();
360            Box::new(oracle::Oracle {
361                regions: parse_csv(&s.regions),
362                compartment: s.compartment,
363            })
364        },
365    },
366    ProviderDescriptor {
367        name: "ovh",
368        display: "OVHcloud",
369        // OVH overloads `regions` as the API endpoint (e.g. "ovh-eu").
370        // Known quirk flagged in the architecture review; kept as-is to
371        // avoid schema migration in this refactor.
372        build: |section| {
373            let s = section.cloned().unwrap_or_default();
374            Box::new(ovh::Ovh {
375                project: s.project,
376                endpoint: s.regions,
377            })
378        },
379    },
380    ProviderDescriptor {
381        name: "leaseweb",
382        display: "Leaseweb",
383        build: |_| Box::new(leaseweb::Leaseweb),
384    },
385    ProviderDescriptor {
386        name: "i3d",
387        display: "i3D.net",
388        build: |_| Box::new(i3d::I3d),
389    },
390    ProviderDescriptor {
391        name: "transip",
392        display: "TransIP",
393        build: |_| Box::new(transip::TransIp),
394    },
395    ProviderDescriptor {
396        name: "teleport",
397        display: "Teleport",
398        build: |section| {
399            let s = section.cloned().unwrap_or_default();
400            Box::new(teleport::Teleport {
401                identity_file: s.identity_file,
402            })
403        },
404    },
405    ProviderDescriptor {
406        name: "netbox",
407        display: "NetBox",
408        build: |section| {
409            let s = section.cloned().unwrap_or_default();
410            Box::new(netbox::NetBox {
411                base_url: s.url,
412                verify_tls: s.verify_tls,
413                filter: s.filter,
414            })
415        },
416    },
417];
418
419/// Look up a descriptor by bare provider name. Internal helper; public wrappers
420/// below strip any `:label` suffix before calling this, so callers cannot
421/// accidentally pass a labeled id (`proxmox:server1`) and silently miss.
422fn descriptor(name: &str) -> Option<&'static ProviderDescriptor> {
423    PROVIDERS.iter().find(|p| p.name == name)
424}
425
426/// Return the bare provider name, stripping an optional `:label` suffix.
427/// `ProviderConfigId` is the canonical home for this split; this helper keeps
428/// `&str`-only public APIs label-tolerant without forcing every caller to
429/// parse first.
430fn bare_provider_name(name: &str) -> &str {
431    name.split_once(':').map(|(p, _)| p).unwrap_or(name)
432}
433
434/// All known provider names, in registration order.
435pub const PROVIDER_NAMES: &[&str] = &[
436    "digitalocean",
437    "vultr",
438    "linode",
439    "hetzner",
440    "upcloud",
441    "proxmox",
442    "aws",
443    "scaleway",
444    "gcp",
445    "azure",
446    "tailscale",
447    "oracle",
448    "ovh",
449    "leaseweb",
450    "i3d",
451    "transip",
452    "teleport",
453    "netbox",
454];
455
456// Compile-time guard: PROVIDER_NAMES and PROVIDERS must stay in lockstep.
457const _: () = {
458    assert!(
459        PROVIDER_NAMES.len() == PROVIDERS.len(),
460        "PROVIDER_NAMES and PROVIDERS length must match",
461    );
462};
463
464/// Get a provider implementation by name with default configuration. Accepts
465/// either a bare provider name (`"proxmox"`) or a labeled id (`"proxmox:server1"`).
466pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
467    descriptor(bare_provider_name(name)).map(|d| (d.build)(None))
468}
469
470/// Get a provider implementation configured from a provider section. The bare
471/// provider name comes from `section.id.provider`, so labeled configs resolve
472/// the right descriptor by construction; passing a separate `name` was
473/// historically a foot-gun (issue #51) where callers handed in the labeled id
474/// string and the lookup missed the registry.
475pub fn get_provider_with_config(section: &config::ProviderSection) -> Option<Box<dyn Provider>> {
476    descriptor(section.provider()).map(|d| (d.build)(Some(section)))
477}
478
479/// Display name for a provider (e.g. "digitalocean" -> "DigitalOcean"). Accepts
480/// either a bare name or a labeled id; unknown names fall back to the input.
481pub fn provider_display_name(name: &str) -> &str {
482    descriptor(bare_provider_name(name))
483        .map(|d| d.display)
484        .unwrap_or(name)
485}
486
487/// Create an HTTP agent with explicit timeouts.
488pub(crate) fn http_agent() -> ureq::Agent {
489    ureq::Agent::config_builder()
490        .timeout_global(Some(std::time::Duration::from_secs(30)))
491        .max_redirects(0)
492        .build()
493        .new_agent()
494}
495
496/// Create an HTTP agent that accepts invalid/self-signed TLS certificates.
497pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
498    Ok(ureq::Agent::config_builder()
499        .timeout_global(Some(std::time::Duration::from_secs(30)))
500        .max_redirects(0)
501        .tls_config(
502            ureq::tls::TlsConfig::builder()
503                .provider(ureq::tls::TlsProvider::NativeTls)
504                .disable_verification(true)
505                .build(),
506        )
507        .build()
508        .new_agent())
509}
510
511/// Strip CIDR suffix (/64, /128, etc.) from an IP address.
512/// Some provider APIs return IPv6 addresses with prefix length (e.g. "2600:3c00::1/128").
513/// SSH requires bare addresses without CIDR notation.
514pub(crate) fn strip_cidr(ip: &str) -> &str {
515    // Only strip if it looks like a CIDR suffix (slash followed by digits)
516    if let Some(pos) = ip.rfind('/')
517        && ip[pos + 1..].bytes().all(|b| b.is_ascii_digit())
518        && pos + 1 < ip.len()
519    {
520        return &ip[..pos];
521    }
522    ip
523}
524
525/// RFC 3986 percent-encoding for URL query parameters.
526/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '_', '.', '~').
527pub(crate) fn percent_encode(s: &str) -> String {
528    let mut result = String::with_capacity(s.len());
529    for byte in s.bytes() {
530        match byte {
531            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
532                result.push(byte as char);
533            }
534            _ => {
535                result.push_str(&format!("%{:02X}", byte));
536            }
537        }
538    }
539    result
540}
541
542/// Date components from a Unix epoch timestamp (no chrono dependency).
543pub(crate) struct EpochDate {
544    pub year: u64,
545    pub month: u64, // 1-based
546    pub day: u64,   // 1-based
547    pub hours: u64,
548    pub minutes: u64,
549    pub seconds: u64,
550    /// Days since epoch (for weekday calculation)
551    pub epoch_days: u64,
552}
553
554/// Convert Unix epoch seconds to date components.
555pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
556    let secs_per_day = 86400u64;
557    let epoch_days = epoch_secs / secs_per_day;
558    let mut remaining_days = epoch_days;
559    let day_secs = epoch_secs % secs_per_day;
560
561    let mut year = 1970u64;
562    loop {
563        let leap =
564            year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
565        let days_in_year = if leap { 366 } else { 365 };
566        if remaining_days < days_in_year {
567            break;
568        }
569        remaining_days -= days_in_year;
570        year += 1;
571    }
572
573    let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
574    let days_per_month: [u64; 12] = [
575        31,
576        if leap { 29 } else { 28 },
577        31,
578        30,
579        31,
580        30,
581        31,
582        31,
583        30,
584        31,
585        30,
586        31,
587    ];
588    let mut month = 0usize;
589    while month < 12 && remaining_days >= days_per_month[month] {
590        remaining_days -= days_per_month[month];
591        month += 1;
592    }
593
594    EpochDate {
595        year,
596        month: (month + 1) as u64,
597        day: remaining_days + 1,
598        hours: day_secs / 3600,
599        minutes: (day_secs % 3600) / 60,
600        seconds: day_secs % 60,
601        epoch_days,
602    }
603}
604
605/// The one status a caller reads rather than reports. A provider that takes
606/// the status as data instead of letting ureq raise it still has to map this
607/// one, because back-off depends on it.
608pub(crate) const HTTP_TOO_MANY_REQUESTS: u16 = 429;
609
610/// Map a ureq error to a ProviderError.
611fn map_ureq_error(err: ureq::Error) -> ProviderError {
612    match err {
613        ureq::Error::StatusCode(code) => match code {
614            401 | 403 => {
615                error!("[external] HTTP {code}: authentication failed");
616                ProviderError::AuthFailed
617            }
618            HTTP_TOO_MANY_REQUESTS => {
619                warn!("[external] HTTP 429: rate limited");
620                ProviderError::RateLimited
621            }
622            _ => {
623                error!("[external] HTTP {code}");
624                ProviderError::Http(format!("HTTP {}", code))
625            }
626        },
627        other => {
628            error!("[external] Request failed: {other}");
629            ProviderError::Http(other.to_string())
630        }
631    }
632}
633
634/// Upper bound on pages fetched from one paginated list endpoint. A safety
635/// valve for a provider that never signals its last page; 500 pages covers any
636/// realistic account.
637pub(crate) const MAX_PAGES: u64 = 500;
638
639/// One mapped page from a paginated list endpoint.
640pub(crate) struct PageResult {
641    /// Hosts mapped from this page, appended to the running total.
642    pub hosts: Vec<ProviderHost>,
643    /// Whether another page should be fetched.
644    pub more: bool,
645}
646
647/// Drive a paginated list endpoint under one shared cancellation, runaway-guard
648/// and partial-failure contract, so every JSON list provider behaves the same.
649///
650/// `fetch_page(index)` performs one request (0-based page index), parses it and
651/// maps entries into a `PageResult`. The closure owns its own cursor or
652/// page-number state across calls.
653///
654/// Failure policy, matching what `sync` relies on: a failure on the first page
655/// (nothing collected) propagates as a hard error so the provider is skipped
656/// and the config left untouched. A failure on a later page returns the hosts
657/// gathered so far as `PartialResult`, so add and update still run while remove
658/// and stale marking are suppressed upstream. `AuthFailed`, `RateLimited` and
659/// `Cancelled` always propagate immediately, even mid-run, because they
660/// invalidate the whole sync.
661pub(crate) fn paginate<F>(
662    cancel: &AtomicBool,
663    mut fetch_page: F,
664) -> Result<Vec<ProviderHost>, ProviderError>
665where
666    F: FnMut(u64) -> Result<PageResult, ProviderError>,
667{
668    let mut hosts = Vec::new();
669    let mut index = 0u64;
670    loop {
671        if cancel.load(Ordering::Relaxed) {
672            return Err(ProviderError::Cancelled);
673        }
674        match fetch_page(index) {
675            Ok(page) => {
676                hosts.extend(page.hosts);
677                if !page.more {
678                    return Ok(hosts);
679                }
680            }
681            Err(
682                e @ (ProviderError::Cancelled
683                | ProviderError::AuthFailed
684                | ProviderError::RateLimited),
685            ) => return Err(e),
686            Err(e) => {
687                if hosts.is_empty() {
688                    return Err(e);
689                }
690                debug!(
691                    "[external] paginate: page {} failed after {} hosts collected, returning partial result ({e})",
692                    index + 1,
693                    hosts.len()
694                );
695                return Err(ProviderError::PartialResult {
696                    hosts,
697                    failures: 1,
698                    total: (index + 1) as usize,
699                });
700            }
701        }
702        index += 1;
703        if index >= MAX_PAGES {
704            debug!(
705                "[purple] paginate: reached MAX_PAGES ({MAX_PAGES}) guard, stopping with {} hosts",
706                hosts.len()
707            );
708            return Ok(hosts);
709        }
710    }
711}
712
713#[cfg(test)]
714#[path = "mod_tests.rs"]
715mod tests;