Skip to main content

isb_core/egress/
policy.rs

1//! The egress policy: which hostnames and ports a sandbox may reach, and
2//! which secrets may be put on the wire towards which hosts.
3//!
4//! `egress:` in a spec is one of `none`, a list of `host[:port]` entries, or
5//! an object with `allow` and `secrets`. [`EgressSpec`] is that field as
6//! written; [`Policy`] is the validated form the plumbing, the instance
7//! config and the proxy all use.
8
9use std::collections::BTreeSet;
10use std::fmt;
11use std::str::FromStr;
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16use crate::error::{Error, Result};
17
18/// The port an entry gets when it names none.
19pub const DEFAULT_PORT: u16 = 443;
20
21/// A host the policy names: one exact hostname, or every name under a
22/// suffix (`*.example.com`, not `example.com` itself).
23#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub enum HostPattern {
25    Exact(String),
26    /// The domain after `*.`.
27    Suffix(String),
28}
29
30impl HostPattern {
31    /// Whether `host` (already lower-cased, no trailing dot) is covered.
32    pub fn matches(&self, host: &str) -> bool {
33        match self {
34            HostPattern::Exact(h) => h == host,
35            HostPattern::Suffix(d) => host
36                .strip_suffix(d.as_str())
37                .is_some_and(|rest| rest.len() > 1 && rest.ends_with('.')),
38        }
39    }
40}
41
42/// One allowed `host[:port]`.
43#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub struct Entry {
45    pub host: HostPattern,
46    pub port: u16,
47}
48
49/// A hostname lower-cased and checked: DNS labels only, never an IP address.
50pub fn normalize_host(raw: &str) -> Result<String> {
51    let h = raw.trim().trim_end_matches('.').to_ascii_lowercase();
52    if h.is_empty() || h.len() > 253 {
53        return Err(Error::invalid(format!(
54            "egress host {raw:?}: not a hostname"
55        )));
56    }
57    if h.parse::<std::net::IpAddr>().is_ok() || h.starts_with('[') {
58        return Err(Error::invalid(format!(
59            "egress host {raw:?}: egress is by hostname; an IP address cannot be allowed"
60        )));
61    }
62    for label in h.split('.') {
63        let ok = !label.is_empty()
64            && label.len() <= 63
65            && !label.starts_with('-')
66            && !label.ends_with('-')
67            && label
68                .bytes()
69                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'));
70        if !ok {
71            return Err(Error::invalid(format!(
72                "egress host {raw:?}: {label:?} is not a valid DNS label"
73            )));
74        }
75    }
76    Ok(h)
77}
78
79impl FromStr for Entry {
80    type Err = Error;
81
82    /// `host`, `host:port`, `*.suffix` or `*.suffix:port`.
83    fn from_str(s: &str) -> Result<Entry> {
84        let s = s.trim();
85        let (host, port) = match s.rsplit_once(':') {
86            Some((h, p)) => {
87                let port = p
88                    .parse::<u16>()
89                    .ok()
90                    .filter(|p| *p != 0)
91                    .ok_or_else(|| Error::invalid(format!("egress {s:?}: bad port {p:?}")))?;
92                (h, port)
93            }
94            None => (s, DEFAULT_PORT),
95        };
96        let host = match host.strip_prefix("*.") {
97            Some(domain) => {
98                let d = normalize_host(domain)?;
99                if !d.contains('.') {
100                    return Err(Error::invalid(format!(
101                        "egress {s:?}: a wildcard needs a domain of at least two labels"
102                    )));
103                }
104                HostPattern::Suffix(d)
105            }
106            None if host.contains('*') => {
107                return Err(Error::invalid(format!(
108                    "egress {s:?}: `*` is only allowed as a leading `*.`"
109                )));
110            }
111            None => HostPattern::Exact(normalize_host(host)?),
112        };
113        Ok(Entry { host, port })
114    }
115}
116
117impl fmt::Display for Entry {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match &self.host {
120            HostPattern::Exact(h) => write!(f, "{h}:{}", self.port),
121            HostPattern::Suffix(d) => write!(f, "*.{d}:{}", self.port),
122        }
123    }
124}
125
126impl Entry {
127    /// Whether a connection to `host:port` is allowed by this entry.
128    pub fn allows(&self, host: &str, port: u16) -> bool {
129        self.port == port && self.host.matches(host)
130    }
131}
132
133impl Serialize for Entry {
134    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
135        s.collect_str(self)
136    }
137}
138
139impl<'de> Deserialize<'de> for Entry {
140    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Entry, D::Error> {
141        let s = String::deserialize(d)?;
142        s.parse().map_err(serde::de::Error::custom)
143    }
144}
145
146/// A secret the sandbox sees only as a placeholder: the real value is put
147/// on the wire towards `hosts` and nowhere else.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SecretBinding {
150    /// The environment variable holding the placeholder inside the guest.
151    pub env: String,
152    /// The secret in the org's store.
153    pub secret: String,
154    /// Where the real value may be sent (TLS only).
155    pub hosts: Vec<Entry>,
156    /// What the guest sees. Derived from the sandbox and the variable.
157    pub placeholder: String,
158}
159
160/// A secret as written in a spec: `{env, secret?, hosts}`, or the string
161/// `ENV[=SECRET]@host1,host2`.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct EgressSecretSpec {
164    /// The environment variable the guest sees (holding a placeholder).
165    pub env: String,
166    /// The secret in the org's store (default: the variable's name).
167    pub secret: Option<String>,
168    /// Hosts the real value may be sent to: `host[:port]`, port 443 by default.
169    pub hosts: Vec<String>,
170}
171
172#[derive(Serialize, Deserialize, JsonSchema)]
173#[serde(untagged)]
174enum SecretRepr {
175    /// `ENV[=SECRET]@host1,host2`
176    Short(String),
177    /// A secret with its fields spelled out.
178    Full {
179        /// The environment variable the guest sees (holding a placeholder).
180        env: String,
181        /// The secret in the org's store (default: the variable's name).
182        #[serde(default, skip_serializing_if = "Option::is_none")]
183        secret: Option<String>,
184        /// Hosts the real value may be sent to: `host[:port]`, port 443 by default.
185        hosts: Vec<String>,
186    },
187}
188
189impl JsonSchema for EgressSecretSpec {
190    fn schema_name() -> std::borrow::Cow<'static, str> {
191        "EgressSecretSpec".into()
192    }
193
194    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
195        SecretRepr::json_schema(g)
196    }
197}
198
199impl EgressSecretSpec {
200    /// Parse `ENV[=SECRET]@host1,host2` (the `--secret` flag's form).
201    pub fn parse(s: &str) -> Result<EgressSecretSpec> {
202        let (head, hosts) = s.split_once('@').ok_or_else(|| {
203            Error::invalid(format!(
204                "egress secret {s:?}: expected NAME@host1,host2 (the hosts it may be sent to)"
205            ))
206        })?;
207        let (env, secret) = match head.split_once('=') {
208            Some((e, s)) => (e, Some(s.to_string())),
209            None => (head, None),
210        };
211        Ok(EgressSecretSpec {
212            env: env.to_string(),
213            secret,
214            hosts: hosts.split(',').map(|h| h.trim().to_string()).collect(),
215        })
216    }
217}
218
219impl Serialize for EgressSecretSpec {
220    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
221        SecretRepr::Full {
222            env: self.env.clone(),
223            secret: self.secret.clone(),
224            hosts: self.hosts.clone(),
225        }
226        .serialize(s)
227    }
228}
229
230impl<'de> Deserialize<'de> for EgressSecretSpec {
231    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
232        match SecretRepr::deserialize(d)? {
233            SecretRepr::Short(s) => EgressSecretSpec::parse(&s).map_err(serde::de::Error::custom),
234            SecretRepr::Full { env, secret, hosts } => Ok(EgressSecretSpec { env, secret, hosts }),
235        }
236    }
237}
238
239/// The `egress:` field as written in a spec.
240#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct EgressSpec {
242    /// `host[:port]` entries (`*.example.com` for a suffix); port 443 by default.
243    pub allow: Vec<String>,
244    /// `egress: none`: no network at all. Also what an empty list means.
245    pub none: bool,
246    pub secrets: Vec<EgressSecretSpec>,
247}
248
249#[derive(Serialize, Deserialize, JsonSchema)]
250#[serde(untagged)]
251enum EgressRepr {
252    /// `none`: no network at all.
253    Keyword(String),
254    /// The hosts the sandbox may reach: `host[:port]` (port 443 by default),
255    /// `*.example.com` for subdomains.
256    List(Vec<String>),
257    /// Hosts and secrets.
258    Full {
259        /// The hosts the sandbox may reach: `host[:port]`.
260        #[serde(default)]
261        allow: Vec<String>,
262        /// Secrets the guest sees only as placeholders.
263        #[serde(default)]
264        secrets: Vec<EgressSecretSpec>,
265    },
266}
267
268impl JsonSchema for EgressSpec {
269    fn schema_name() -> std::borrow::Cow<'static, str> {
270        "EgressSpec".into()
271    }
272
273    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
274        EgressRepr::json_schema(g)
275    }
276}
277
278impl Serialize for EgressSpec {
279    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
280        let repr = if self.none {
281            EgressRepr::Keyword("none".into())
282        } else if self.secrets.is_empty() {
283            EgressRepr::List(self.allow.clone())
284        } else {
285            EgressRepr::Full {
286                allow: self.allow.clone(),
287                secrets: self.secrets.clone(),
288            }
289        };
290        repr.serialize(s)
291    }
292}
293
294impl<'de> Deserialize<'de> for EgressSpec {
295    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
296        Ok(match EgressRepr::deserialize(d)? {
297            EgressRepr::Keyword(k) if k.eq_ignore_ascii_case("none") => EgressSpec {
298                none: true,
299                ..Default::default()
300            },
301            EgressRepr::Keyword(k) => {
302                return Err(serde::de::Error::custom(format!(
303                    "egress: {k:?} is not `none`, a list of host[:port] or an object with allow/secrets"
304                )));
305            }
306            EgressRepr::List(allow) => EgressSpec {
307                allow,
308                ..Default::default()
309            },
310            EgressRepr::Full { allow, secrets } => EgressSpec {
311                allow,
312                none: false,
313                secrets,
314            },
315        })
316    }
317}
318
319impl EgressSpec {
320    /// `egress: none`.
321    pub fn none() -> EgressSpec {
322        EgressSpec {
323            none: true,
324            ..Default::default()
325        }
326    }
327
328    /// Allow these `host[:port]` entries.
329    pub fn allow<I, S>(hosts: I) -> EgressSpec
330    where
331        I: IntoIterator<Item = S>,
332        S: Into<String>,
333    {
334        EgressSpec {
335            allow: hosts.into_iter().map(Into::into).collect(),
336            ..Default::default()
337        }
338    }
339}
340
341/// The validated policy. An empty one denies everything (`egress: none`).
342#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
343pub struct Policy {
344    /// Everything reachable: the allow list and every secret's hosts.
345    pub entries: Vec<Entry>,
346    pub secrets: Vec<SecretBinding>,
347}
348
349/// Most entries a sandbox may carry: the proxy and the DNS config are sized
350/// by it.
351pub const MAX_ENTRIES: usize = 256;
352/// Most secrets a sandbox may carry.
353pub const MAX_SECRETS: usize = 16;
354
355fn valid_env_name(n: &str) -> bool {
356    let mut b = n.bytes();
357    b.next()
358        .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_')
359        && b.all(|c| c.is_ascii_alphanumeric() || c == b'_')
360        && n.len() <= 128
361}
362
363impl Policy {
364    /// Validate a spec's `egress` for the sandbox whose egress network is
365    /// `network` (which the placeholders are derived from).
366    pub fn from_spec(spec: &EgressSpec, network: &str) -> Result<Policy> {
367        if spec.none && (!spec.allow.is_empty() || !spec.secrets.is_empty()) {
368            return Err(Error::invalid(
369                "egress: none cannot be combined with hosts or secrets",
370            ));
371        }
372        let mut entries = BTreeSet::new();
373        for a in &spec.allow {
374            entries.insert(a.parse::<Entry>()?);
375        }
376        let mut secrets: Vec<SecretBinding> = Vec::new();
377        for s in &spec.secrets {
378            if !valid_env_name(&s.env) {
379                return Err(Error::invalid(format!(
380                    "egress secret: {:?} is not an environment variable name",
381                    s.env
382                )));
383            }
384            if secrets.iter().any(|b| b.env == s.env) {
385                return Err(Error::invalid(format!(
386                    "egress secret {}: listed twice",
387                    s.env
388                )));
389            }
390            let store = s.secret.clone().unwrap_or_else(|| s.env.clone());
391            crate::secrets::validate_name(&store)?;
392            if s.hosts.is_empty() {
393                return Err(Error::invalid(format!(
394                    "egress secret {}: name the hosts it may be sent to (NAME@host1,host2)",
395                    s.env
396                )));
397            }
398            let hosts = s
399                .hosts
400                .iter()
401                .map(|h| h.parse::<Entry>())
402                .collect::<Result<Vec<_>>>()?;
403            entries.extend(hosts.iter().cloned());
404            secrets.push(SecretBinding {
405                placeholder: placeholder(network, &s.env),
406                env: s.env.clone(),
407                secret: store,
408                hosts,
409            });
410        }
411        if secrets.len() > MAX_SECRETS || entries.len() > MAX_ENTRIES {
412            return Err(Error::invalid(format!(
413                "egress: at most {MAX_ENTRIES} hosts and {MAX_SECRETS} secrets per sandbox"
414            )));
415        }
416        Ok(Policy {
417            entries: entries.into_iter().collect(),
418            secrets,
419        })
420    }
421
422    /// Nothing is reachable: no network, no DNS.
423    pub fn is_none(&self) -> bool {
424        self.entries.is_empty()
425    }
426
427    /// Whether `host:port` is on the list.
428    pub fn allows(&self, host: &str, port: u16) -> bool {
429        self.entries.iter().any(|e| e.allows(host, port))
430    }
431
432    /// Every port the proxy has to listen on.
433    pub fn ports(&self) -> BTreeSet<u16> {
434        self.entries.iter().map(|e| e.port).collect()
435    }
436
437    /// The names DNS answers for, as dnsmasq understands them: a name
438    /// covers itself and everything below it.
439    pub fn dns_names(&self) -> BTreeSet<String> {
440        self.entries
441            .iter()
442            .map(|e| match &e.host {
443                HostPattern::Exact(h) => h.clone(),
444                HostPattern::Suffix(d) => d.clone(),
445            })
446            .collect()
447    }
448
449    /// The secrets that may be put on the wire towards `host:port`.
450    pub fn secrets_for<'a>(
451        &'a self,
452        host: &'a str,
453        port: u16,
454    ) -> impl Iterator<Item = &'a SecretBinding> {
455        self.secrets
456            .iter()
457            .filter(move |s| s.hosts.iter().any(|e| e.allows(host, port)))
458    }
459
460    /// Whether a connection to `host:port` is terminated and rewritten (some
461    /// secret is approved there) rather than passed through.
462    pub fn intercepts(&self, host: &str, port: u16) -> bool {
463        self.secrets_for(host, port).next().is_some()
464    }
465
466    /// For a protocol that shows no name (server-first, or not TLS and not
467    /// HTTP): the one exact host that owns `port`, when there is exactly one.
468    pub fn pinned_host(&self, port: u16) -> Option<&str> {
469        let mut hosts = self.entries.iter().filter(|e| e.port == port);
470        let first = hosts.next()?;
471        let HostPattern::Exact(h) = &first.host else {
472            return None;
473        };
474        hosts.next().is_none().then_some(h.as_str())
475    }
476
477    /// The `host:port` entries as strings, for display.
478    pub fn list(&self) -> Vec<String> {
479        self.entries.iter().map(Entry::to_string).collect()
480    }
481}
482
483/// What the guest sees in place of a secret: stable for a sandbox and a
484/// variable, so reconciling never changes it, and not derived from the value.
485pub fn placeholder(network: &str, env: &str) -> String {
486    let d = ring::digest::digest(
487        &ring::digest::SHA256,
488        format!("isb-egress-placeholder\0{network}\0{env}").as_bytes(),
489    );
490    let hex: String = d.as_ref()[..16]
491        .iter()
492        .map(|b| format!("{b:02x}"))
493        .collect();
494    format!("isb_placeholder_{hex}")
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    fn e(s: &str) -> Entry {
502        s.parse().unwrap()
503    }
504
505    #[test]
506    fn entries_parse_with_default_port() {
507        assert_eq!(
508            e("API.Example.com"),
509            Entry {
510                host: HostPattern::Exact("api.example.com".into()),
511                port: 443
512            }
513        );
514        assert_eq!(e("db.example.com:5432").port, 5432);
515        assert_eq!(
516            e("*.example.com:8443"),
517            Entry {
518                host: HostPattern::Suffix("example.com".into()),
519                port: 8443
520            }
521        );
522        assert_eq!(e("example.com.").to_string(), "example.com:443");
523    }
524
525    #[test]
526    fn bad_entries_are_refused() {
527        for bad in [
528            "",
529            "1.2.3.4",
530            "1.2.3.4:443",
531            "[::1]:443",
532            "example.com:0",
533            "example.com:70000",
534            "example.com:x",
535            "*.com",
536            "*",
537            "a*.example.com",
538            "ex ample.com",
539            "-bad.example.com",
540            "a..b",
541            "https://example.com",
542        ] {
543            assert!(bad.parse::<Entry>().is_err(), "{bad:?} should be refused");
544        }
545    }
546
547    #[test]
548    fn exact_and_suffix_matching() {
549        let exact = e("api.example.com");
550        assert!(exact.allows("api.example.com", 443));
551        assert!(!exact.allows("api.example.com", 80));
552        assert!(!exact.allows("x.api.example.com", 443));
553        assert!(!exact.allows("example.com", 443));
554        let wild = e("*.example.com");
555        assert!(wild.allows("a.example.com", 443));
556        assert!(wild.allows("a.b.example.com", 443));
557        assert!(!wild.allows("example.com", 443), "the apex is not covered");
558        assert!(
559            !wild.allows("badexample.com", 443),
560            "a suffix is a label boundary"
561        );
562        assert!(!wild.allows("a.example.com.evil.com", 443));
563        assert!(!wild.allows("a.example.com", 8443));
564    }
565
566    #[test]
567    fn entries_roundtrip_as_strings() {
568        let v = serde_json::to_string(&e("*.example.com:8443")).unwrap();
569        assert_eq!(v, "\"*.example.com:8443\"");
570        assert_eq!(
571            serde_json::from_str::<Entry>(&v).unwrap(),
572            e("*.example.com:8443")
573        );
574    }
575
576    #[test]
577    fn spec_forms_deserialize() {
578        let none: EgressSpec = serde_yaml_ng::from_str("none").unwrap();
579        assert!(none.none);
580        let list: EgressSpec =
581            serde_yaml_ng::from_str("[api.example.com, '*.cdn.net:80']").unwrap();
582        assert_eq!(list.allow.len(), 2);
583        let full: EgressSpec = serde_yaml_ng::from_str(
584            "allow: [api.example.com]\nsecrets:\n  - GH@api.github.com\n  - {env: K, secret: store-k, hosts: [x.example.com:8443]}\n",
585        )
586        .unwrap();
587        assert_eq!(full.secrets.len(), 2);
588        assert_eq!(full.secrets[0].env, "GH");
589        assert_eq!(full.secrets[1].secret.as_deref(), Some("store-k"));
590        assert!(serde_yaml_ng::from_str::<EgressSpec>("open").is_err());
591        let empty: EgressSpec = serde_yaml_ng::from_str("[]").unwrap();
592        assert!(Policy::from_spec(&empty, "n").unwrap().is_none());
593    }
594
595    #[test]
596    fn spec_roundtrips() {
597        for text in ["none", "[a.example.com]"] {
598            let s: EgressSpec = serde_yaml_ng::from_str(text).unwrap();
599            let back: EgressSpec =
600                serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&s).unwrap()).unwrap();
601            assert_eq!(s, back);
602        }
603    }
604
605    #[test]
606    fn policy_collects_secret_hosts_into_the_allow_list() {
607        let spec = EgressSpec {
608            allow: vec!["registry.npmjs.org".into()],
609            none: false,
610            secrets: vec![EgressSecretSpec::parse("GH_TOKEN=gh-prod@api.github.com").unwrap()],
611        };
612        let p = Policy::from_spec(&spec, "isbbrxabc").unwrap();
613        assert!(p.allows("registry.npmjs.org", 443));
614        assert!(p.allows("api.github.com", 443));
615        assert!(p.intercepts("api.github.com", 443));
616        assert!(!p.intercepts("registry.npmjs.org", 443));
617        assert_eq!(p.secrets[0].secret, "gh-prod");
618        assert_eq!(p.secrets[0].env, "GH_TOKEN");
619        assert!(p.secrets[0].placeholder.starts_with("isb_placeholder_"));
620        assert_eq!(p.ports().into_iter().collect::<Vec<_>>(), vec![443]);
621    }
622
623    #[test]
624    fn secrets_are_validated() {
625        let bad = |s: &str| {
626            Policy::from_spec(
627                &EgressSpec {
628                    secrets: vec![EgressSecretSpec::parse(s).unwrap()],
629                    ..Default::default()
630                },
631                "n",
632            )
633        };
634        assert!(bad("1BAD@a.example.com").is_err());
635        assert!(bad("OK@1.2.3.4").is_err());
636        assert!(bad("OK=../x@a.example.com").is_err());
637        assert!(EgressSecretSpec::parse("NOHOSTS").is_err());
638        let both = EgressSpec {
639            none: true,
640            allow: vec!["a.example.com".into()],
641            ..Default::default()
642        };
643        assert!(Policy::from_spec(&both, "n").is_err());
644    }
645
646    #[test]
647    fn placeholders_are_stable_and_per_sandbox() {
648        assert_eq!(placeholder("n1", "K"), placeholder("n1", "K"));
649        assert_ne!(placeholder("n1", "K"), placeholder("n2", "K"));
650        assert_ne!(placeholder("n1", "K"), placeholder("n1", "J"));
651    }
652
653    #[test]
654    fn dns_names_cover_entries() {
655        let p = Policy::from_spec(
656            &EgressSpec::allow(["api.example.com", "*.cdn.net:80", "api.example.com:8443"]),
657            "n",
658        )
659        .unwrap();
660        assert_eq!(
661            p.dns_names().into_iter().collect::<Vec<_>>(),
662            vec!["api.example.com".to_string(), "cdn.net".to_string()]
663        );
664        assert_eq!(
665            p.ports().into_iter().collect::<Vec<_>>(),
666            vec![80, 443, 8443]
667        );
668    }
669
670    #[test]
671    fn pinned_host_needs_exactly_one_exact_entry_on_the_port() {
672        let p = Policy::from_spec(
673            &EgressSpec::allow([
674                "db.example.com:5432",
675                "a.example.com",
676                "b.example.com",
677                "*.w.net:9000",
678            ]),
679            "n",
680        )
681        .unwrap();
682        assert_eq!(p.pinned_host(5432), Some("db.example.com"));
683        assert_eq!(p.pinned_host(443), None, "two hosts share 443");
684        assert_eq!(p.pinned_host(9000), None, "a suffix names no host");
685        assert_eq!(p.pinned_host(1), None);
686    }
687}