Skip to main content

faucet_core/
resolve.rs

1//! Allow-list resolution of the external resources a config names (#881,
2//! RFC 0013).
3//!
4//! A config can reach outside the process in a handful of ways: a local path,
5//! a URL, a command to run, an environment variable, a `${file:}` read, or a
6//! secret-manager reference. A [`ReferencePolicy`] lists what a config of a
7//! given origin may reach, and everything it does not list is refused.
8//!
9//! Connector config fields that name such a resource carry the
10//! [`RESOURCE_ANNOTATION`] (`x-faucet-resource: path | url | command`) in their
11//! JSON Schema, so a host can check any connector — including one it has never
12//! seen — by walking the schema against the config ([`annotated_resources`]).
13//! In a connector crate:
14//!
15//! ```ignore
16//! #[derive(serde::Deserialize, schemars::JsonSchema)]
17//! pub struct MyConfig {
18//!     #[schemars(extend("x-faucet-resource" = "path"))]
19//!     pub cert_file: Option<String>,
20//! }
21//! ```
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use std::collections::BTreeSet;
26use std::path::{Component, Path, PathBuf};
27
28/// The JSON Schema keyword marking a config field that names an external
29/// resource. Its value is a [`ResourceKind`] (`"path"`, `"url"`, `"command"`).
30pub const RESOURCE_ANNOTATION: &str = "x-faucet-resource";
31
32/// What kind of external resource a config field names.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35#[non_exhaustive]
36pub enum ResourceKind {
37    /// A local file or directory (or a remote object location when the value
38    /// carries a scheme such as `s3://`).
39    Path,
40    /// A network endpoint or connection URL.
41    Url,
42    /// A program the connector runs.
43    Command,
44}
45
46impl ResourceKind {
47    /// The annotation value for this kind.
48    pub fn as_str(self) -> &'static str {
49        match self {
50            ResourceKind::Path => "path",
51            ResourceKind::Url => "url",
52            ResourceKind::Command => "command",
53        }
54    }
55
56    /// Parse an annotation value.
57    pub fn parse(s: &str) -> Option<Self> {
58        match s {
59            "path" => Some(ResourceKind::Path),
60            "url" => Some(ResourceKind::Url),
61            "command" => Some(ResourceKind::Command),
62            _ => None,
63        }
64    }
65}
66
67impl std::fmt::Display for ResourceKind {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.write_str(self.as_str())
70    }
71}
72
73/// One resource a config names, found through a schema annotation.
74#[derive(Debug, Clone, PartialEq, Eq)]
75#[non_exhaustive]
76pub struct ResourceRef {
77    /// JSON pointer of the value inside the config (`/tls/ca_path`).
78    pub pointer: String,
79    pub kind: ResourceKind,
80    pub value: String,
81}
82
83impl ResourceRef {
84    pub fn new(pointer: impl Into<String>, kind: ResourceKind, value: impl Into<String>) -> Self {
85        Self {
86            pointer: pointer.into(),
87            kind,
88            value: value.into(),
89        }
90    }
91}
92
93/// What a refused reference was.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum RefusalKind {
97    /// An `${env:}` / `${secret:}` variable.
98    Env,
99    /// A `${file:}` read.
100    File,
101    /// A secret-manager reference (`${vault:…}`, …).
102    Secret,
103    /// A local path (or remote location) a connector reads or writes.
104    Path,
105    /// A URL a connector connects to.
106    Url,
107    /// A program a connector runs.
108    Command,
109}
110
111/// Why a reference was refused.
112#[derive(Debug, Clone, PartialEq, Eq)]
113#[non_exhaustive]
114pub struct Refusal {
115    pub kind: RefusalKind,
116    /// The reference as written (a variable name, a path, a URL, …).
117    pub subject: String,
118    pub reason: String,
119}
120
121impl Refusal {
122    pub fn new(kind: RefusalKind, subject: impl Into<String>, reason: impl Into<String>) -> Self {
123        Self {
124            kind,
125            subject: subject.into(),
126            reason: reason.into(),
127        }
128    }
129}
130
131impl std::fmt::Display for Refusal {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        write!(f, "{} ({})", self.subject, self.reason)
134    }
135}
136
137impl std::error::Error for Refusal {}
138
139/// The external resources a config of one origin may reach.
140///
141/// Every dimension is either open (`None` — anything) or an allow-list
142/// (`Some(list)`; an empty list allows nothing). [`ReferencePolicy::default`]
143/// is [`ReferencePolicy::deny_all`]: a policy only allows what it is told to.
144#[derive(Debug, Clone, PartialEq, Eq)]
145#[non_exhaustive]
146pub struct ReferencePolicy {
147    path_roots: Option<Vec<PathBuf>>,
148    url_schemes: Option<BTreeSet<String>>,
149    url_hosts: Option<Vec<String>>,
150    env: Option<Vec<String>>,
151    secret_schemes: Option<BTreeSet<String>>,
152    subprocess: bool,
153}
154
155impl Default for ReferencePolicy {
156    fn default() -> Self {
157        Self::deny_all()
158    }
159}
160
161/// Hosts a URL may name only when a policy lists them explicitly: link-local
162/// and cloud instance-metadata endpoints. `host` is compared in its canonical
163/// form ([`canonical_host`]), so `2852039166`, `0xa9fea9fe` or a trailing dot
164/// name the same address as `169.254.169.254`.
165pub fn is_metadata_host(host: &str) -> bool {
166    let canonical = canonical_host(host);
167    let h = canonical.trim_start_matches('[').trim_end_matches(']');
168    if let Ok(ip) = h.parse::<std::net::IpAddr>() {
169        return is_metadata_ip(ip);
170    }
171    matches!(h, "metadata.google.internal" | "metadata")
172}
173
174/// Whether an address is link-local or a cloud instance-metadata endpoint.
175pub fn is_metadata_ip(ip: std::net::IpAddr) -> bool {
176    let v4_meta =
177        |v4: std::net::Ipv4Addr| v4.is_link_local() || v4.octets() == [100, 100, 100, 200];
178    match ip {
179        std::net::IpAddr::V4(v4) => v4_meta(v4),
180        std::net::IpAddr::V6(v6) => {
181            (v6.segments()[0] & 0xffc0) == 0xfe80
182                || v6 == std::net::Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254)
183                || v6.to_ipv4_mapped().is_some_and(v4_meta)
184        }
185    }
186}
187
188/// The canonical, lower-case form of a URL host as a URL parser (and the
189/// system resolver) reads it: numeric IPv4 forms (`2852039166`, `0xa9fea9fe`,
190/// `0251.0376.0251.0376`, `169.254.43518`) become dotted quads, IPv6 is
191/// bracketed and compressed, and a domain loses its trailing dots. A host the
192/// parser rejects (a token, a pattern) is returned lower-cased as written.
193pub fn canonical_host(host: &str) -> String {
194    let raw = host.to_ascii_lowercase();
195    if raw.is_empty() || raw.contains(['$', '{', '}', '*', '/', '?', '#', '@']) {
196        return raw;
197    }
198    match url::Url::parse(&format!("http://{raw}/"))
199        .ok()
200        .and_then(|u| u.host().map(|h| h.to_owned()))
201    {
202        Some(url::Host::Ipv4(v4)) => v4.to_string(),
203        Some(url::Host::Ipv6(v6)) => format!("[{v6}]"),
204        Some(url::Host::Domain(d)) => d.trim_end_matches('.').to_string(),
205        None => raw.trim_end_matches('.').to_string(),
206    }
207}
208
209/// Whether `name` matches `pattern`: exact, `*` (anything) or `PREFIX*`.
210fn name_matches(pattern: &str, name: &str) -> bool {
211    match pattern.strip_suffix('*') {
212        Some(prefix) => name.starts_with(prefix),
213        None => pattern == name,
214    }
215}
216
217/// Whether `host` matches a host pattern: exact, `*` or `*.suffix`.
218fn host_matches(pattern: &str, host: &str) -> bool {
219    let pattern = pattern.to_ascii_lowercase();
220    if pattern == "*" {
221        return true;
222    }
223    match pattern.strip_prefix("*.") {
224        Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")),
225        None => canonical_host(&pattern) == host,
226    }
227}
228
229/// Absolute, symlink-resolved form of `path`: the longest existing prefix of
230/// the path as written is canonicalized by the filesystem (so a `..` after a
231/// symlink climbs out of the link's target, as the kernel would), and the
232/// components after it are appended. `None` when a component after that
233/// prefix is `..` — where it lands depends on directories that do not exist
234/// yet, so it cannot be checked.
235fn normalize(path: &Path) -> Option<PathBuf> {
236    let absolute = if path.is_relative() {
237        std::env::current_dir().ok()?.join(path)
238    } else {
239        path.to_path_buf()
240    };
241    let components: Vec<Component<'_>> = absolute.components().collect();
242    for split in (1..=components.len()).rev() {
243        let prefix: PathBuf = components[..split].iter().collect();
244        let Ok(mut out) = std::fs::canonicalize(&prefix) else {
245            continue;
246        };
247        for c in &components[split..] {
248            match c {
249                Component::CurDir => {}
250                Component::Normal(name) => out.push(name),
251                _ => return None,
252            }
253        }
254        return Some(out);
255    }
256    None
257}
258
259/// The scheme of `value` when it is written `scheme://…` or `scheme:…`, lower
260/// case. A Windows drive letter is not a scheme.
261fn scheme_of(value: &str) -> Option<String> {
262    let colon = value.find(':')?;
263    let scheme = &value[..colon];
264    let valid = scheme.len() > 1
265        && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
266        && scheme
267            .chars()
268            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
269    let rest = value[colon + 1..]
270        .split(['/', '?', '#'])
271        .next()
272        .unwrap_or("");
273    let port = !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit());
274    (valid && !port).then(|| scheme.to_ascii_lowercase())
275}
276
277/// A local path a value names behind a `file:` / `sqlite:` prefix, or the
278/// value itself when it carries no scheme. `None` for a remote location.
279fn local_part(value: &str) -> Option<&str> {
280    match scheme_of(value).as_deref() {
281        Some("file") => {
282            let rest = &value[5..];
283            Some(rest.strip_prefix("//").unwrap_or(rest))
284        }
285        Some("sqlite") => {
286            let rest = &value[7..];
287            let rest = rest.strip_prefix("//").unwrap_or(rest);
288            Some(rest.split('?').next().unwrap_or(rest))
289        }
290        Some(_) => None,
291        None => Some(value),
292    }
293}
294
295/// Whether a scheme-less value is a relative path (`jobs/${id}`) rather than
296/// `host[:port][/…]`: its first segment is neither dotted, ported nor
297/// `localhost`.
298fn is_relative_path(value: &str) -> bool {
299    match value.split_once('/') {
300        Some((first, _)) => {
301            !first.contains(['.', ':', '[']) && !first.eq_ignore_ascii_case("localhost")
302        }
303        None => false,
304    }
305}
306
307/// The hosts a URL's authority names (several for `h1,h2` connection
308/// strings), in canonical form ([`canonical_host`]), without userinfo or
309/// port. A host taken from a `${…}` token is returned lower-cased as written.
310pub fn url_hosts(value: &str) -> Vec<String> {
311    let after = match value.find("://") {
312        Some(i) => &value[i + 3..],
313        None => match scheme_of(value) {
314            Some(s) => &value[s.len() + 1..],
315            None => value,
316        },
317    };
318    let authority = after.split(['/', '?', '#']).next().unwrap_or("");
319    let hosts = match authority.rfind('@') {
320        Some(at) => &authority[at + 1..],
321        None => authority,
322    };
323    hosts
324        .split(',')
325        .map(|h| {
326            let h = if h.starts_with('[') {
327                h.find(']').map_or(h, |end| &h[..=end])
328            } else {
329                h.rsplit_once(':')
330                    .filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit()))
331                    .map_or(h, |(host, _)| host)
332            };
333            canonical_host(h)
334        })
335        .filter(|h| !h.is_empty())
336        .collect()
337}
338
339impl ReferencePolicy {
340    /// Everything allowed: any path, URL, variable and secret, and programs.
341    pub fn open() -> Self {
342        Self {
343            path_roots: None,
344            url_schemes: None,
345            url_hosts: None,
346            env: None,
347            secret_schemes: None,
348            subprocess: true,
349        }
350    }
351
352    /// Nothing allowed.
353    pub fn deny_all() -> Self {
354        Self {
355            path_roots: Some(Vec::new()),
356            url_schemes: Some(BTreeSet::new()),
357            url_hosts: Some(Vec::new()),
358            env: Some(Vec::new()),
359            secret_schemes: Some(BTreeSet::new()),
360            subprocess: false,
361        }
362    }
363
364    /// Allow local paths only under these directories.
365    pub fn with_path_roots<I, P>(mut self, roots: I) -> Self
366    where
367        I: IntoIterator<Item = P>,
368        P: Into<PathBuf>,
369    {
370        self.path_roots = Some(roots.into_iter().map(Into::into).collect());
371        self
372    }
373
374    /// Allow any local path.
375    pub fn any_path(mut self) -> Self {
376        self.path_roots = None;
377        self
378    }
379
380    /// Allow URLs only with these schemes (`https`, `postgres`, …).
381    pub fn with_url_schemes<I, S>(mut self, schemes: I) -> Self
382    where
383        I: IntoIterator<Item = S>,
384        S: AsRef<str>,
385    {
386        self.url_schemes = Some(
387            schemes
388                .into_iter()
389                .map(|s| s.as_ref().to_ascii_lowercase())
390                .collect(),
391        );
392        self
393    }
394
395    /// Allow URLs of any scheme.
396    pub fn any_url_scheme(mut self) -> Self {
397        self.url_schemes = None;
398        self
399    }
400
401    /// Allow URLs only to these hosts (`api.example.com`, `*.example.com`,
402    /// `10.0.0.5`). A link-local or metadata host is reachable only when listed.
403    pub fn with_url_hosts<I, S>(mut self, hosts: I) -> Self
404    where
405        I: IntoIterator<Item = S>,
406        S: Into<String>,
407    {
408        self.url_hosts = Some(hosts.into_iter().map(Into::into).collect());
409        self
410    }
411
412    /// Allow URLs to any host except link-local and metadata endpoints.
413    pub fn any_url_host(mut self) -> Self {
414        self.url_hosts = None;
415        self
416    }
417
418    /// Allow only these environment variables (`NAME` or `PREFIX*`).
419    pub fn with_env<I, S>(mut self, names: I) -> Self
420    where
421        I: IntoIterator<Item = S>,
422        S: Into<String>,
423    {
424        self.env = Some(names.into_iter().map(Into::into).collect());
425        self
426    }
427
428    /// Allow any environment variable.
429    pub fn any_env(mut self) -> Self {
430        self.env = None;
431        self
432    }
433
434    /// Allow only these secret-manager schemes (`vault`, `aws-sm`, …).
435    pub fn with_secret_schemes<I, S>(mut self, schemes: I) -> Self
436    where
437        I: IntoIterator<Item = S>,
438        S: AsRef<str>,
439    {
440        self.secret_schemes = Some(
441            schemes
442                .into_iter()
443                .map(|s| s.as_ref().to_ascii_lowercase())
444                .collect(),
445        );
446        self
447    }
448
449    /// Allow any secret-manager scheme.
450    pub fn any_secret_scheme(mut self) -> Self {
451        self.secret_schemes = None;
452        self
453    }
454
455    /// Whether connectors that run a program are allowed.
456    pub fn with_subprocess(mut self, allowed: bool) -> Self {
457        self.subprocess = allowed;
458        self
459    }
460
461    /// The allowed path roots, `None` when any path is allowed.
462    pub fn path_roots(&self) -> Option<&[PathBuf]> {
463        self.path_roots.as_deref()
464    }
465
466    /// The allowed URL schemes, `None` when any scheme is allowed.
467    pub fn url_schemes(&self) -> Option<&BTreeSet<String>> {
468        self.url_schemes.as_ref()
469    }
470
471    /// The allowed URL hosts, `None` when any host is allowed.
472    pub fn url_host_patterns(&self) -> Option<&[String]> {
473        self.url_hosts.as_deref()
474    }
475
476    /// The allowed environment variables, `None` when any is allowed.
477    pub fn env_patterns(&self) -> Option<&[String]> {
478        self.env.as_deref()
479    }
480
481    /// The allowed secret-manager schemes, `None` when any is allowed.
482    pub fn secret_schemes(&self) -> Option<&BTreeSet<String>> {
483        self.secret_schemes.as_ref()
484    }
485
486    /// Whether connectors that run a program are allowed.
487    pub fn allows_subprocess(&self) -> bool {
488        self.subprocess
489    }
490
491    /// Check an `${env:NAME}` / `${secret:NAME}` reference.
492    pub fn check_env(&self, name: &str) -> Result<(), Refusal> {
493        match &self.env {
494            None => Ok(()),
495            Some(list) if list.iter().any(|p| name_matches(p, name)) => Ok(()),
496            Some(_) => Err(Refusal::new(
497                RefusalKind::Env,
498                name,
499                "environment variable not in the allow-list",
500            )),
501        }
502    }
503
504    /// Check a `${file:PATH}` reference.
505    pub fn check_file(&self, path: &str) -> Result<(), Refusal> {
506        self.check_local(path)
507            .map_err(|reason| Refusal::new(RefusalKind::File, path, reason))
508    }
509
510    /// Check a secret-manager reference (`${vault:…}`, …).
511    pub fn check_secret(&self, scheme: &str, reference: &str) -> Result<(), Refusal> {
512        match &self.secret_schemes {
513            None => Ok(()),
514            Some(set) if set.contains(&scheme.to_ascii_lowercase()) => Ok(()),
515            Some(_) => Err(Refusal::new(
516                RefusalKind::Secret,
517                format!("{scheme}:{reference}"),
518                "secret-manager scheme not in the allow-list",
519            )),
520        }
521    }
522
523    /// Check a path a connector reads or writes. A value with a remote scheme
524    /// (`s3://…`) is checked as a URL.
525    pub fn check_path(&self, value: &str) -> Result<(), Refusal> {
526        match local_part(value) {
527            Some(local) => self
528                .check_local(local)
529                .map_err(|reason| Refusal::new(RefusalKind::Path, value, reason)),
530            None => self.check_url(value),
531        }
532    }
533
534    fn check_local(&self, path: &str) -> Result<(), String> {
535        let Some(roots) = &self.path_roots else {
536            return Ok(());
537        };
538        if path.is_empty() || path == ":memory:" || path == "/dev/null" {
539            return Ok(());
540        }
541        let dynamic = path.find(['*', '?', '[', '{']).unwrap_or(path.len());
542        let (base, rest) = match path[..dynamic].rfind('/') {
543            Some(slash) if dynamic < path.len() => (&path[..slash.max(1)], &path[slash + 1..]),
544            None if dynamic < path.len() => (".", path),
545            _ => (path, ""),
546        };
547        if rest.split('/').any(|seg| seg == "..") {
548            return Err("a pattern or token segment may not contain `..`".into());
549        }
550        let Some(target) = normalize(Path::new(base)) else {
551            return Err("a `..` below a directory that does not exist cannot be checked".into());
552        };
553        if roots
554            .iter()
555            .filter_map(|r| normalize(r))
556            .any(|root| target.starts_with(root))
557        {
558            return Ok(());
559        }
560        Err(if roots.is_empty() {
561            "local paths are not allowed".into()
562        } else {
563            format!(
564                "outside the allowed path roots ({})",
565                roots
566                    .iter()
567                    .map(|r| r.display().to_string())
568                    .collect::<Vec<_>>()
569                    .join(", ")
570            )
571        })
572    }
573
574    /// Check a URL a connector connects to. `file:` / `sqlite:` URLs are
575    /// checked as paths, a scheme-less `host[:port]` as a host, and a relative
576    /// reference (`/jobs`, `./x`) names no host of its own and passes.
577    pub fn check_url(&self, value: &str) -> Result<(), Refusal> {
578        let refuse = |reason: String| Refusal::new(RefusalKind::Url, value, reason);
579        let scheme = scheme_of(value).filter(|_| !value.starts_with('/'));
580        match scheme.as_deref() {
581            Some("file") | Some("sqlite") => return self.check_path(value),
582            None if value.starts_with(['/', '.', '?', '#']) => return Ok(()),
583            None if is_relative_path(value) => return Ok(()),
584            _ => {}
585        }
586        if let (Some(scheme), Some(allowed)) = (&scheme, &self.url_schemes)
587            && !allowed.contains(scheme)
588        {
589            return Err(refuse(format!(
590                "URL scheme `{scheme}` not in the allow-list"
591            )));
592        }
593        for host in url_hosts(value) {
594            if host.contains("${") || host.contains('*') {
595                if self.url_hosts.is_some() {
596                    return Err(refuse(
597                        "a host taken from a token cannot be checked against the host allow-list"
598                            .into(),
599                    ));
600                }
601                continue;
602            }
603            match &self.url_hosts {
604                Some(list) if list.iter().any(|p| host_matches(p, &host)) => {}
605                Some(_) => return Err(refuse(format!("host `{host}` not in the allow-list"))),
606                None if is_metadata_host(&host) => {
607                    return Err(refuse(format!(
608                        "host `{host}` is a link-local / metadata endpoint"
609                    )));
610                }
611                None => {}
612            }
613        }
614        Ok(())
615    }
616
617    /// Check a program a connector runs.
618    pub fn check_command(&self, value: &str) -> Result<(), Refusal> {
619        if self.subprocess {
620            Ok(())
621        } else {
622            Err(Refusal::new(
623                RefusalKind::Command,
624                value,
625                "connectors that run a program are not allowed",
626            ))
627        }
628    }
629
630    /// Check one annotated resource.
631    pub fn check(&self, r: &ResourceRef) -> Result<(), Refusal> {
632        match r.kind {
633            ResourceKind::Path => self.check_path(&r.value),
634            ResourceKind::Url => self.check_url(&r.value),
635            ResourceKind::Command => self.check_command(&r.value),
636        }
637    }
638}
639
640const MAX_DEPTH: usize = 64;
641
642fn deref<'a>(root: &'a Value, node: &'a Value) -> &'a Value {
643    let mut cur = node;
644    for _ in 0..MAX_DEPTH {
645        let Some(r) = cur.get("$ref").and_then(Value::as_str) else {
646            return cur;
647        };
648        let Some(pointer) = r.strip_prefix('#') else {
649            return cur;
650        };
651        match root.pointer(pointer) {
652            Some(next) => cur = next,
653            None => return cur,
654        }
655    }
656    cur
657}
658
659fn annotation_of(node: &Value) -> Option<ResourceKind> {
660    node.get(RESOURCE_ANNOTATION)
661        .and_then(Value::as_str)
662        .and_then(ResourceKind::parse)
663}
664
665/// Every schema node `node` stands for: itself (dereferenced) and the
666/// branches of its `oneOf` / `anyOf` / `allOf`, recursively.
667fn alternatives<'a>(root: &'a Value, node: &'a Value, out: &mut Vec<&'a Value>, depth: usize) {
668    if depth > MAX_DEPTH {
669        return;
670    }
671    out.push(node);
672    let node = deref(root, node);
673    if !std::ptr::eq(node, *out.last().expect("pushed")) {
674        out.push(node);
675    }
676    for key in ["oneOf", "anyOf", "allOf"] {
677        if let Some(Value::Array(branches)) = node.get(key) {
678            for b in branches {
679                alternatives(root, b, out, depth + 1);
680            }
681        }
682    }
683}
684
685fn escape_pointer(key: &str) -> String {
686    key.replace('~', "~0").replace('/', "~1")
687}
688
689fn visit(
690    root: &Value,
691    node: &Value,
692    value: &Value,
693    pointer: &str,
694    out: &mut Vec<ResourceRef>,
695    depth: usize,
696) {
697    if depth > MAX_DEPTH {
698        return;
699    }
700    let mut alts = Vec::new();
701    alternatives(root, node, &mut alts, 0);
702    let kind = alts.iter().find_map(|n| annotation_of(n));
703    match value {
704        Value::String(s) => {
705            if let Some(kind) = kind {
706                out.push(ResourceRef::new(pointer, kind, s.clone()));
707            }
708        }
709        Value::Array(items) => {
710            for (i, item) in items.iter().enumerate() {
711                let p = format!("{pointer}/{i}");
712                if let (Some(kind), Value::String(s)) = (kind, item) {
713                    out.push(ResourceRef::new(p.clone(), kind, s.clone()));
714                }
715                for alt in &alts {
716                    if let Some(items_schema) = alt.get("items").filter(|v| v.is_object()) {
717                        visit(root, items_schema, item, &p, out, depth + 1);
718                    }
719                }
720            }
721        }
722        Value::Object(map) => {
723            for (k, child) in map {
724                let p = format!("{pointer}/{}", escape_pointer(k));
725                for alt in &alts {
726                    let declared = alt.get("properties").and_then(|props| props.get(k));
727                    if let Some(s) = declared {
728                        visit(root, s, child, &p, out, depth + 1);
729                    } else if let Some(s) =
730                        alt.get("additionalProperties").filter(|v| v.is_object())
731                    {
732                        visit(root, s, child, &p, out, depth + 1);
733                    }
734                }
735            }
736        }
737        _ => {}
738    }
739}
740
741/// The resources `config` names through fields `schema` annotates with
742/// [`RESOURCE_ANNOTATION`]. `schema` is a connector's config schema (with its
743/// own `$defs`); every `oneOf` / `anyOf` / `allOf` branch is considered, so a
744/// tagged-enum field is found whichever variant the config picked.
745pub fn annotated_resources(schema: &Value, config: &Value) -> Vec<ResourceRef> {
746    let mut out = Vec::new();
747    visit(schema, schema, config, "", &mut out, 0);
748    let mut seen = BTreeSet::new();
749    out.retain(|r| seen.insert((r.pointer.clone(), r.kind, r.value.clone())));
750    out
751}
752
753fn collect_fields(
754    root: &Value,
755    node: &Value,
756    path: &str,
757    out: &mut BTreeSet<(String, ResourceKind)>,
758    depth: usize,
759) {
760    if depth > 12 {
761        return;
762    }
763    let mut alts = Vec::new();
764    alternatives(root, node, &mut alts, 0);
765    if let Some(kind) = alts.iter().find_map(|n| annotation_of(n))
766        && !path.is_empty()
767    {
768        out.insert((path.to_string(), kind));
769    }
770    for alt in alts {
771        if let Some(Value::Object(props)) = alt.get("properties") {
772            for (k, s) in props {
773                let p = if path.is_empty() {
774                    k.clone()
775                } else {
776                    format!("{path}.{k}")
777                };
778                collect_fields(root, s, &p, out, depth + 1);
779            }
780        }
781        if let Some(items) = alt.get("items").filter(|v| v.is_object()) {
782            collect_fields(root, items, &format!("{path}[]"), out, depth + 1);
783        }
784        if let Some(extra) = alt.get("additionalProperties").filter(|v| v.is_object()) {
785            collect_fields(root, extra, &format!("{path}{{}}"), out, depth + 1);
786        }
787    }
788}
789
790/// Every annotated field a schema declares, as dotted paths (`tls.ca_path`;
791/// `[]` marks an array element, `{}` a map value) with their kind.
792pub fn annotated_fields(schema: &Value) -> Vec<(String, ResourceKind)> {
793    let mut out = BTreeSet::new();
794    collect_fields(schema, schema, "", &mut out, 0);
795    out.into_iter().collect()
796}
797
798/// Whether a schema declares at least one field of `kind`.
799pub fn declares(schema: &Value, kind: ResourceKind) -> bool {
800    annotated_fields(schema).iter().any(|(_, k)| *k == kind)
801}
802
803/// The JSON Schema keyword (value `true`) on a top-level boolean config field
804/// that, set to `true`, confines a connector whose own input can otherwise
805/// reach host files or the network — a SQL query that can `ATTACH` or
806/// `read_csv` — to the resources its configuration declares. A host loading a
807/// config it does not trust forces every such field on:
808///
809/// ```ignore
810/// #[schemars(extend("x-faucet-sandbox" = true))]
811/// pub sandbox: bool,
812/// ```
813pub const SANDBOX_ANNOTATION: &str = "x-faucet-sandbox";
814
815/// The top-level config fields `schema` marks with [`SANDBOX_ANNOTATION`],
816/// across every `oneOf` / `anyOf` / `allOf` branch of the root.
817pub fn sandbox_fields(schema: &Value) -> Vec<String> {
818    let mut alts = Vec::new();
819    alternatives(schema, schema, &mut alts, 0);
820    let mut out = BTreeSet::new();
821    for alt in alts {
822        let Some(Value::Object(props)) = alt.get("properties") else {
823            continue;
824        };
825        for (name, field) in props {
826            let mut field_alts = Vec::new();
827            alternatives(schema, field, &mut field_alts, 0);
828            if field_alts
829                .iter()
830                .any(|n| n.get(SANDBOX_ANNOTATION) == Some(&Value::Bool(true)))
831            {
832                out.insert(name.clone());
833            }
834        }
835    }
836    out.into_iter().collect()
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use serde_json::json;
843
844    /// A schema that refers to itself through `oneOf`, and a config nested
845    /// deeper than the walk follows, end the walk rather than recursing on.
846    #[test]
847    fn self_referential_schemas_and_deep_configs_stop_at_the_depth_cap() {
848        let s = json!({"oneOf": [{"$ref": "#"}], "type": "object"});
849        assert!(annotated_fields(&s).is_empty());
850        let nested = json!({
851            "type": "object",
852            "properties": {"next": {"$ref": "#"}, "path": {"type": "string", "x-faucet-resource": "path"}}
853        });
854        let mut config = json!({"path": "/deepest"});
855        for _ in 0..MAX_DEPTH + 2 {
856            config = json!({"next": config});
857        }
858        let found = annotated_resources(&nested, &config);
859        assert!(found.is_empty(), "{found:?}");
860        let shallow = annotated_resources(&nested, &json!({"next": {"path": "/a"}}));
861        assert_eq!(shallow.len(), 1);
862    }
863
864    #[test]
865    fn a_scheme_without_slashes_names_its_host() {
866        assert_eq!(url_hosts("mysql:db.internal:3306/app"), vec!["db.internal"]);
867    }
868
869    #[test]
870    fn a_branch_without_properties_declares_no_sandbox_field() {
871        assert!(sandbox_fields(&json!({"oneOf": [{"type": "string"}]})).is_empty());
872    }
873
874    #[test]
875    fn sandbox_fields_are_read_from_the_root_and_its_branches() {
876        assert!(sandbox_fields(&schema()).is_empty());
877        let s = json!({
878            "type": "object",
879            "properties": {
880                "sandbox": {"type": "boolean", "x-faucet-sandbox": true},
881                "confine": {"$ref": "#/$defs/Flag"},
882                "off": {"type": "boolean", "x-faucet-sandbox": false},
883                "query": {"type": "string"}
884            },
885            "allOf": [{"properties": {"lock": {"anyOf": [{"type": "boolean", "x-faucet-sandbox": true}]}}}],
886            "$defs": {"Flag": {"type": "boolean", "x-faucet-sandbox": true}}
887        });
888        assert_eq!(sandbox_fields(&s), vec!["confine", "lock", "sandbox"]);
889    }
890
891    fn schema() -> Value {
892        json!({
893            "type": "object",
894            "properties": {
895                "path": {"type": "string", "x-faucet-resource": "path"},
896                "name": {"type": "string"},
897                "files": {"type": "array", "items": {"type": "string"}, "x-faucet-resource": "path"},
898                "tls": {"anyOf": [{"$ref": "#/$defs/Tls"}, {"type": "null"}]},
899                "auth": {"oneOf": [
900                    {"type": "object", "properties": {"type": {"const": "key"}, "config": {"$ref": "#/$defs/Key"}}},
901                    {"type": "object", "properties": {"type": {"const": "none"}}}
902                ]},
903                "endpoints": {"type": "object", "additionalProperties": {"type": "string", "x-faucet-resource": "url"}},
904                "cmd": {"$ref": "#/$defs/Cmd", "x-faucet-resource": "command"},
905                "hops": {"type": "array", "items": {"$ref": "#/$defs/Hop"}}
906            },
907            "$defs": {
908                "Tls": {"type": "object", "properties": {"ca_path": {"type": ["string", "null"], "x-faucet-resource": "path"}}},
909                "Key": {"type": "object", "properties": {"key_file": {"type": "string", "x-faucet-resource": "path"}}},
910                "Cmd": {"type": "string"},
911                "Hop": {"type": "object", "properties": {"url": {"type": "string", "x-faucet-resource": "url"}}}
912            }
913        })
914    }
915
916    #[test]
917    fn finds_every_annotated_value() {
918        let config = json!({
919            "path": "/data/in.csv",
920            "name": "/etc/passwd",
921            "files": ["/a", "/b"],
922            "tls": {"ca_path": "/certs/ca.pem"},
923            "auth": {"type": "key", "config": {"key_file": "/keys/k"}},
924            "endpoints": {"a/b": "https://x.example"},
925            "cmd": "/bin/tap",
926            "hops": [{"url": "http://h1"}, {"url": 5}],
927            "unknown": {"path": "/ignored"}
928        });
929        let found = annotated_resources(&schema(), &config);
930        let mut got: Vec<(&str, ResourceKind, &str)> = found
931            .iter()
932            .map(|r| (r.pointer.as_str(), r.kind, r.value.as_str()))
933            .collect();
934        got.sort();
935        assert_eq!(
936            got,
937            vec![
938                ("/auth/config/key_file", ResourceKind::Path, "/keys/k"),
939                ("/cmd", ResourceKind::Command, "/bin/tap"),
940                ("/endpoints/a~1b", ResourceKind::Url, "https://x.example"),
941                ("/files/0", ResourceKind::Path, "/a"),
942                ("/files/1", ResourceKind::Path, "/b"),
943                ("/hops/0/url", ResourceKind::Url, "http://h1"),
944                ("/path", ResourceKind::Path, "/data/in.csv"),
945                ("/tls/ca_path", ResourceKind::Path, "/certs/ca.pem"),
946            ]
947        );
948    }
949
950    #[test]
951    fn lists_annotated_fields() {
952        let fields = annotated_fields(&schema());
953        let names: Vec<String> = fields.iter().map(|(p, k)| format!("{p}:{k}")).collect();
954        assert_eq!(
955            names,
956            vec![
957                "auth.config.key_file:path",
958                "cmd:command",
959                "endpoints{}:url",
960                "files:path",
961                "hops[].url:url",
962                "path:path",
963                "tls.ca_path:path",
964            ]
965        );
966        assert!(declares(&schema(), ResourceKind::Command));
967        assert!(!declares(&json!({"type": "object"}), ResourceKind::Path));
968    }
969
970    #[test]
971    fn a_recursive_schema_terminates() {
972        let s = json!({"$ref": "#/$defs/A", "$defs": {"A": {"$ref": "#/$defs/A"}}});
973        assert!(annotated_resources(&s, &json!({"a": 1})).is_empty());
974        let s = json!({"$defs": {"N": {"type": "object", "properties": {"n": {"$ref": "#/$defs/N"}, "p": {"type": "string", "x-faucet-resource": "path"}}}}, "$ref": "#/$defs/N"});
975        let deepest = &annotated_fields(&s)[0].0;
976        assert!(
977            deepest.starts_with("n.n.") && deepest.ends_with(".p"),
978            "{deepest}"
979        );
980        let deep = json!({"n": {"n": {"p": "/x"}}});
981        assert_eq!(annotated_resources(&s, &deep)[0].pointer, "/n/n/p");
982        assert!(annotated_resources(&json!({"$ref": "http://x"}), &json!("v")).is_empty());
983        assert!(annotated_resources(&json!({"$ref": "#/missing"}), &json!("v")).is_empty());
984    }
985
986    #[test]
987    fn the_derive_attribute_produces_the_annotation() {
988        #[derive(schemars::JsonSchema)]
989        #[allow(dead_code)]
990        struct Tls {
991            #[schemars(extend("x-faucet-resource" = "path"))]
992            ca: Option<std::path::PathBuf>,
993        }
994        #[derive(schemars::JsonSchema)]
995        #[allow(dead_code)]
996        struct Cfg {
997            #[schemars(extend("x-faucet-resource" = "url"))]
998            url: String,
999            #[schemars(extend("x-faucet-resource" = "command"))]
1000            program: Vec<String>,
1001            tls: Option<Tls>,
1002        }
1003        let schema = serde_json::to_value(schemars::schema_for!(Cfg)).unwrap();
1004        let fields = annotated_fields(&schema);
1005        assert_eq!(
1006            fields,
1007            vec![
1008                ("program".to_string(), ResourceKind::Command),
1009                ("tls.ca".to_string(), ResourceKind::Path),
1010                ("url".to_string(), ResourceKind::Url),
1011            ]
1012        );
1013        let found = annotated_resources(
1014            &schema,
1015            &json!({"url": "https://h", "program": ["tap"], "tls": {"ca": "/c"}}),
1016        );
1017        assert_eq!(found.len(), 3);
1018    }
1019
1020    #[test]
1021    fn kinds_round_trip() {
1022        for k in [ResourceKind::Path, ResourceKind::Url, ResourceKind::Command] {
1023            assert_eq!(ResourceKind::parse(k.as_str()), Some(k));
1024            assert_eq!(serde_json::to_value(k).unwrap(), json!(k.as_str()));
1025        }
1026        assert_eq!(ResourceKind::parse("other"), None);
1027    }
1028
1029    #[test]
1030    fn the_default_denies_everything() {
1031        let p = ReferencePolicy::default();
1032        assert_eq!(p, ReferencePolicy::deny_all());
1033        assert!(p.check_env("HOME").is_err());
1034        assert!(p.check_file("/tmp/x").is_err());
1035        assert!(p.check_secret("vault", "a/b").is_err());
1036        assert!(p.check_path("/tmp/x").is_err());
1037        assert!(p.check_url("https://example.com").is_err());
1038        assert!(p.check_command("/bin/sh").is_err());
1039        assert!(!p.allows_subprocess());
1040        let e = p.check_path("/tmp/x").unwrap_err();
1041        assert_eq!(e.kind, RefusalKind::Path);
1042        assert!(e.to_string().contains("not allowed"), "{e}");
1043    }
1044
1045    #[test]
1046    fn the_open_policy_allows_everything_but_metadata_hosts() {
1047        let p = ReferencePolicy::open();
1048        assert!(p.check_env("HOME").is_ok());
1049        assert!(p.check_file("/etc/passwd").is_ok());
1050        assert!(p.check_secret("vault", "a").is_ok());
1051        assert!(p.check_path("/etc/passwd").is_ok());
1052        assert!(p.check_url("https://example.com").is_ok());
1053        assert!(p.check_command("/bin/sh").is_ok());
1054        for bad in [
1055            "http://169.254.169.254/latest",
1056            "http://[fe80::1]/",
1057            "http://[fd00:ec2::254]/",
1058            "http://[::ffff:169.254.169.254]/",
1059            "http://metadata.google.internal/x",
1060            "http://100.100.100.200/",
1061        ] {
1062            assert!(p.check_url(bad).is_err(), "{bad}");
1063        }
1064        assert!(p.check_url("http://[::1]:80/").is_ok());
1065        let listed = ReferencePolicy::open().with_url_hosts(["169.254.169.254"]);
1066        assert!(listed.check_url("http://169.254.169.254/").is_ok());
1067    }
1068
1069    #[test]
1070    fn env_and_secret_allow_lists() {
1071        let p = ReferencePolicy::deny_all()
1072            .with_env(["APP_*", "TOKEN"])
1073            .with_secret_schemes(["Vault"]);
1074        assert!(p.check_env("APP_KEY").is_ok());
1075        assert!(p.check_env("TOKEN").is_ok());
1076        assert!(p.check_env("TOKEN2").is_err());
1077        assert!(p.check_secret("vault", "x").is_ok());
1078        assert_eq!(
1079            p.check_secret("aws-sm", "x").unwrap_err().subject,
1080            "aws-sm:x"
1081        );
1082        assert_eq!(p.env_patterns().unwrap().len(), 2);
1083        assert!(p.secret_schemes().unwrap().contains("vault"));
1084        assert!(ReferencePolicy::deny_all().any_env().check_env("X").is_ok());
1085        assert!(
1086            ReferencePolicy::deny_all()
1087                .any_secret_scheme()
1088                .check_secret("x", "y")
1089                .is_ok()
1090        );
1091    }
1092
1093    #[test]
1094    fn paths_must_sit_under_a_root() {
1095        let dir = std::env::temp_dir().join(format!("faucet-resolve-{}", std::process::id()));
1096        std::fs::create_dir_all(dir.join("sub")).unwrap();
1097        let p = ReferencePolicy::deny_all().with_path_roots([&dir]);
1098        assert_eq!(p.path_roots().unwrap().len(), 1);
1099        let d = dir.display();
1100        for ok in [
1101            format!("{d}/in.csv"),
1102            format!("{d}/sub/new/out.jsonl"),
1103            format!("{d}/*.csv"),
1104            format!("{d}/sub/part-{{part}}.jsonl"),
1105            format!("{d}/x/${{now.date}}.csv"),
1106            format!("file://{d}/a"),
1107            format!("sqlite:{d}/db.sqlite?mode=rwc"),
1108            format!("sqlite://{d}/db.sqlite"),
1109            ":memory:".to_string(),
1110            "/dev/null".to_string(),
1111            String::new(),
1112        ] {
1113            assert!(p.check_path(&ok).is_ok(), "{ok}");
1114        }
1115        for bad in [
1116            "/etc/passwd".to_string(),
1117            format!("{d}/../escape"),
1118            format!("{d}/*/../../etc/*"),
1119            format!("{d}x/sibling"),
1120            "relative/elsewhere".to_string(),
1121        ] {
1122            assert!(p.check_path(&bad).is_err(), "{bad}");
1123        }
1124        assert!(p.check_file(&format!("{d}/a")).is_ok());
1125        assert_eq!(
1126            p.check_file("/etc/hosts").unwrap_err().kind,
1127            RefusalKind::File
1128        );
1129        #[cfg(unix)]
1130        {
1131            let link = dir.join("link");
1132            let _ = std::fs::remove_file(&link);
1133            std::os::unix::fs::symlink("/etc", &link).unwrap();
1134            assert!(p.check_path(&format!("{d}/link/passwd")).is_err());
1135        }
1136        assert!(p.check_path(&format!("{d}/sub/../in.csv")).is_ok());
1137        assert!(p.check_path(&format!("{d}/missing/../in.csv")).is_err());
1138        let cwd = ReferencePolicy::deny_all().with_path_roots(["."]);
1139        assert!(cwd.check_path("data/in.csv").is_ok());
1140        assert!(cwd.check_path("*.csv").is_ok());
1141        assert!(
1142            ReferencePolicy::deny_all()
1143                .any_path()
1144                .check_path("/x")
1145                .is_ok()
1146        );
1147        let _ = std::fs::remove_dir_all(&dir);
1148    }
1149
1150    #[test]
1151    fn a_remote_location_in_a_path_field_is_a_url() {
1152        let p = ReferencePolicy::open().with_url_hosts(["bucket"]);
1153        assert!(p.check_path("s3://bucket/key").is_ok());
1154        assert_eq!(
1155            p.check_path("s3://other/key").unwrap_err().kind,
1156            RefusalKind::Url
1157        );
1158        assert!(p.check_path("C:/data/x").is_ok());
1159    }
1160
1161    #[test]
1162    fn urls_are_checked_by_scheme_and_host() {
1163        let p = ReferencePolicy::open()
1164            .with_url_schemes(["https", "postgres"])
1165            .with_url_hosts(["api.example.com", "*.internal", "db"]);
1166        assert!(p.url_schemes().unwrap().contains("https"));
1167        assert_eq!(p.url_host_patterns().unwrap().len(), 3);
1168        for ok in [
1169            "https://api.example.com/v1",
1170            "https://user:p@ss@api.example.com:8443/x?q#f",
1171            "https://a.b.internal/",
1172            "postgres://u:p@db:5432/x",
1173            "postgres://db,db/x",
1174            "db:5432",
1175            "/tmp/local",
1176        ] {
1177            assert!(p.check_url(ok).is_ok(), "{ok}");
1178        }
1179        for bad in [
1180            "http://api.example.com/",
1181            "https://evil.example.com/",
1182            "https://internal/",
1183            "postgres://db,evil/x",
1184            "https://${parent.host}/x",
1185            "evil:5432",
1186        ] {
1187            assert!(p.check_url(bad).is_err(), "{bad}");
1188        }
1189        let any_host = ReferencePolicy::open();
1190        assert!(any_host.check_url("https://${parent.host}/x").is_ok());
1191        assert!(any_host.check_url("unix:///var/run/x.sock").is_ok());
1192        let star = ReferencePolicy::open().with_url_hosts(["*"]);
1193        assert!(star.check_url("https://anything/").is_ok());
1194        let paths = ReferencePolicy::open()
1195            .with_path_roots(Vec::<PathBuf>::new())
1196            .with_url_hosts(Vec::<String>::new());
1197        assert!(paths.check_url("file:///etc/passwd").is_err());
1198        assert!(paths.check_url("sqlite:/etc/x.db").is_err());
1199        assert!(paths.check_url("file:./rel").is_err());
1200        assert!(paths.check_url("/jobs/1").is_ok());
1201        assert!(paths.check_url("jobs/${job_id}").is_ok());
1202        assert!(paths.check_url("localhost/x").is_err());
1203        assert!(paths.check_url("api.example.com/x").is_err());
1204        assert!(
1205            ReferencePolicy::deny_all()
1206                .any_url_scheme()
1207                .any_url_host()
1208                .check_url("ftp://x/")
1209                .is_ok()
1210        );
1211    }
1212
1213    #[test]
1214    fn commands_follow_the_subprocess_switch() {
1215        let r = ResourceRef::new("/executable", ResourceKind::Command, "/bin/tap");
1216        assert!(ReferencePolicy::deny_all().check(&r).is_err());
1217        assert!(
1218            ReferencePolicy::deny_all()
1219                .with_subprocess(true)
1220                .check(&r)
1221                .is_ok()
1222        );
1223        let url = ResourceRef::new("/u", ResourceKind::Url, "https://x");
1224        assert!(ReferencePolicy::open().check(&url).is_ok());
1225        let path = ResourceRef::new("/p", ResourceKind::Path, "/x");
1226        assert!(ReferencePolicy::open().check(&path).is_ok());
1227    }
1228
1229    #[cfg(unix)]
1230    #[test]
1231    fn a_parent_step_after_a_symlink_follows_the_link_target() {
1232        let base = std::env::temp_dir().join(format!("faucet-resolve-ln-{}", std::process::id()));
1233        let root = base.join("root");
1234        let outside = base.join("outside");
1235        std::fs::create_dir_all(&root).unwrap();
1236        std::fs::create_dir_all(outside.join("inner")).unwrap();
1237        std::fs::write(outside.join("secret"), "x").unwrap();
1238        std::os::unix::fs::symlink(outside.join("inner"), root.join("l")).unwrap();
1239        let p = ReferencePolicy::deny_all().with_path_roots([&root]);
1240        let r = root.display();
1241        assert!(p.check_path(&format!("{r}/l/../secret")).is_err());
1242        assert!(p.check_path(&format!("{r}/l/../new.csv")).is_err());
1243        assert!(p.check_path(&format!("{r}/l/../*.csv")).is_err());
1244        assert!(p.check_path(&format!("{r}/ok.csv")).is_ok());
1245        let _ = std::fs::remove_dir_all(&base);
1246    }
1247
1248    #[test]
1249    fn numeric_and_dotted_forms_of_a_metadata_host_are_refused() {
1250        let p = ReferencePolicy::open();
1251        for bad in [
1252            "http://2852039166/latest",
1253            "http://0xa9fea9fe/latest",
1254            "http://0xA9.0xFE.0xA9.0xFE/",
1255            "http://0251.0376.0251.0376/",
1256            "http://169.254.43518/",
1257            "http://169.254.169.254./latest",
1258            "http://metadata.google.internal./x",
1259            "http://METADATA.google.internal/x",
1260            "http://[0:0:0:0:0:ffff:a9fe:a9fe]/",
1261            "postgres://u@db,2852039166:5432/x",
1262        ] {
1263            assert!(p.check_url(bad).is_err(), "{bad}");
1264        }
1265        assert!(is_metadata_host("2852039166"));
1266        assert!(!is_metadata_host("example.com."));
1267        assert_eq!(canonical_host("Example.COM."), "example.com");
1268        assert_eq!(canonical_host("${host}"), "${host}");
1269        assert_eq!(canonical_host("0x7f000001"), "127.0.0.1");
1270        let listed = ReferencePolicy::open().with_url_hosts(["api.example.com"]);
1271        assert!(listed.check_url("https://API.example.com./v1").is_ok());
1272        assert!(listed.check_url("https://0x7f000001/").is_err());
1273    }
1274
1275    #[test]
1276    fn helpers() {
1277        assert_eq!(scheme_of("C:/x"), None);
1278        assert_eq!(scheme_of("HTTPS://x").as_deref(), Some("https"));
1279        assert_eq!(scheme_of("no-colon"), None);
1280        assert_eq!(scheme_of("bad scheme:x"), None);
1281        assert_eq!(url_hosts("https://[::1]:8080/x"), vec!["[::1]".to_string()]);
1282        assert_eq!(url_hosts("h:notaport"), vec!["h:notaport".to_string()]);
1283        assert!(!host_matches("*.example.com", "example.com"));
1284        assert_eq!(
1285            normalize(Path::new("/./faucet-no-such-dir/./x")),
1286            Some(PathBuf::from("/faucet-no-such-dir/x"))
1287        );
1288        assert_eq!(normalize(Path::new("/faucet-no-such-dir/../etc")), None);
1289        let e = Refusal::new(RefusalKind::Command, "x", "why");
1290        assert_eq!(e.to_string(), "x (why)");
1291        assert_eq!(ResourceKind::Url.to_string(), "url");
1292    }
1293}