Skip to main content

bynk/registry/
conformance.rs

1//! `bynk registry conformance` (first-party registry track R0, #2311): run the
2//! registry protocol's observable behaviour against a registry URL, and report
3//! each check.
4//!
5//! Servers are judged by the client's own code wherever it can be used — the
6//! `config.json` and key-chain reading, line verification, archive fetching
7//! and checksumming are exactly what a consumer runs. Only requests a client
8//! would never make (a bad token, a mismatched URL, a hostile archive, an
9//! oversized declared length) go through `transport::request` directly.
10//!
11//! A full run publishes one throwaway package, `<org>/conformance_<utc>_<hex>`,
12//! and yanks every version it published: indexes are append-only, so that
13//! package stays behind, yanked, and the report names it (decision B).
14//! `--read-only` publishes nothing.
15
16use std::path::{Path, PathBuf};
17
18use bynk_project::manifest::RegistryConfig;
19
20use super::protocol::{Config, ConfigTrust, IndexLine, Signers, fingerprint, verify};
21use super::transport::{self, Auth, Body};
22use super::{Client, archive};
23
24/// A failed check: the registry breaks the protocol.
25pub const NONCONFORMANT: &str = "bynk.registry.nonconformant";
26
27/// What to run against.
28#[derive(Debug, Clone)]
29pub struct Options {
30    /// The registry's index URL.
31    pub index: String,
32    /// The organisation the token may publish and yank under.
33    pub org: String,
34    /// The registry name the token is found under (`BYNK_REGISTRY_<NAME>_TOKEN`).
35    pub name: String,
36    /// A key to pin, `ed25519:…`; trusted on first use for the run otherwise.
37    pub key: Option<String>,
38    /// Publish nothing: check `config.json` and an existing package only.
39    pub read_only: bool,
40    /// The existing `org/package` a `--read-only` run verifies.
41    pub package: Option<String>,
42    /// A token that can't act for `org`, for the `auth.scope` check:
43    /// `Err(variable)` when the variable it was asked to come from is unset.
44    pub narrow_token: Option<Result<String, String>>,
45    /// The upload cap to test `413` against.
46    pub max_upload: u64,
47}
48
49/// A check's outcome.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Outcome {
52    Pass,
53    Fail,
54    Skip,
55}
56
57impl Outcome {
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Outcome::Pass => "pass",
61            Outcome::Fail => "fail",
62            Outcome::Skip => "skip",
63        }
64    }
65}
66
67/// Every check a full (publishing) run reports, in order, so the set of ids is
68/// stable whatever fails: a check a run couldn't reach is reported skipped.
69pub const FULL_RUN: &[&str] = &[
70    "config.shape",
71    "config.chain",
72    "config.delegations",
73    "publish.created",
74    "publish.read-back",
75    "publish.signer",
76    "publish.retry-same-tree",
77    "publish.conflict",
78    "refuse.other-package",
79    "refuse.other-version",
80    "refuse.junk",
81    "refuse.traversal",
82    "refuse.path-dependency",
83    "refuse.clean",
84    "auth.missing",
85    "auth.unknown",
86    "auth.scope",
87    "limits.too-large",
88    "consume.resolve",
89    "yank.yank",
90    "yank.unyank",
91    "yank.missing",
92    "errors.shape",
93];
94
95/// One check's result.
96#[derive(Debug, Clone)]
97pub struct Check {
98    /// A stable id, `group.name`.
99    pub id: &'static str,
100    pub outcome: Outcome,
101    pub detail: String,
102    /// The protocol spec section the check holds a registry to.
103    pub spec: &'static str,
104}
105
106/// A run's results.
107#[derive(Debug, Clone, Default)]
108pub struct Report {
109    /// The registry's declared `id`, once `config.json` was read.
110    pub registry_id: Option<String>,
111    /// The throwaway package a full run published (and yanked).
112    pub published: Option<String>,
113    pub checks: Vec<Check>,
114}
115
116impl Report {
117    pub fn failed(&self) -> usize {
118        self.count(Outcome::Fail)
119    }
120
121    pub fn count(&self, outcome: Outcome) -> usize {
122        self.checks.iter().filter(|c| c.outcome == outcome).count()
123    }
124
125    /// The report as JSON: the registry, the package, and each check.
126    pub fn render_json(&self) -> String {
127        let checks: Vec<serde_json::Value> = self
128            .checks
129            .iter()
130            .map(|c| {
131                serde_json::json!({
132                    "id": c.id,
133                    "outcome": c.outcome.as_str(),
134                    "detail": c.detail,
135                    "spec": c.spec,
136                })
137            })
138            .collect();
139        let doc = serde_json::json!({
140            "registry": self.registry_id,
141            "published": self.published,
142            "passed": self.count(Outcome::Pass),
143            "failed": self.count(Outcome::Fail),
144            "skipped": self.count(Outcome::Skip),
145            "checks": checks,
146        });
147        serde_json::to_string_pretty(&doc).expect("a JSON value renders") + "\n"
148    }
149}
150
151const S_CONFIG: &str = "/docs/registry-protocol/#configjson";
152const S_TRUST: &str = "/docs/registry-protocol/#trust";
153const S_INDEX: &str = "/docs/registry-protocol/#index-files";
154const S_ARCHIVES: &str = "/docs/registry-protocol/#archives";
155const S_PUBLISH: &str = "/docs/registry-protocol/#publish-api";
156const S_DELEGATION: &str = "/docs/registry-protocol/#delegation-and-revocation";
157const S_WITHDRAWALS: &str = "/docs/registry-protocol/#withdrawals";
158const SPEC_FOR_SKIP: &str = "/docs/registry-protocol/#conformance";
159
160struct Run<'o> {
161    opts: &'o Options,
162    report: Report,
163    work: PathBuf,
164    /// #2316: the registry's `config.json` as served, and who may sign its
165    /// lines, once `config.delegations` has read them.
166    config: Option<(Config, Signers)>,
167}
168
169impl Run<'_> {
170    fn record(&mut self, id: &'static str, spec: &'static str, result: Result<String, String>) {
171        let (outcome, detail) = match result {
172            Ok(d) => (Outcome::Pass, d),
173            Err(d) => (Outcome::Fail, d),
174        };
175        self.report.checks.push(Check {
176            id,
177            outcome,
178            detail,
179            spec,
180        });
181    }
182
183    /// Report every full-run check not yet reported as skipped, with `why`.
184    fn skip_rest(&mut self, why: &str) {
185        for id in FULL_RUN {
186            if !self.report.checks.iter().any(|c| c.id == *id) {
187                self.skip(id, SPEC_FOR_SKIP, why);
188            }
189        }
190    }
191
192    fn skip(&mut self, id: &'static str, spec: &'static str, why: &str) {
193        self.report.checks.push(Check {
194            id,
195            outcome: Outcome::Skip,
196            detail: why.to_string(),
197            spec,
198        });
199    }
200}
201
202/// Run the suite.
203pub fn run(opts: &Options) -> Report {
204    let work = scratch_dir();
205    let mut run = Run {
206        opts,
207        report: Report::default(),
208        work: work.clone(),
209        config: None,
210    };
211    checks(&mut run);
212    let _ = std::fs::remove_dir_all(&work);
213    run.report
214}
215
216fn client(opts: &Options) -> Client {
217    Client::new(
218        vec![RegistryConfig {
219            name: opts.name.clone(),
220            index: opts.index.clone(),
221            organisations: vec![opts.org.clone()],
222            mirror_of: None,
223            key: opts.key.clone(),
224        }],
225        None,
226    )
227}
228
229fn checks(run: &mut Run<'_>) {
230    let opts = run.opts;
231    // config.*: the client's own reading of `config.json` and its chain.
232    let mut reader = client(opts);
233    let token = super::token_for(&opts.name, Some(&opts.org), &mut Vec::new()).map(|(t, _)| t);
234    let api = match reader.open(&opts.name) {
235        Ok(o) => {
236            run.report.registry_id = Some(o.id.clone());
237            let id = o.id.clone();
238            let keys = o.keys.len();
239            let api = o.api.clone();
240            let index = o.index.clone();
241            let served = raw_config(&index);
242            let protocol = match &served {
243                Ok(c) if !c.delegations.is_empty() || !c.revoked.is_empty() => 2,
244                _ => 1,
245            };
246            run.record(
247                "config.shape",
248                S_CONFIG,
249                Ok(format!(
250                    "protocol {protocol}, id `{id}`, dl, auth{}",
251                    if api.is_some() { ", api" } else { "" }
252                )),
253            );
254            run.record(
255                "config.chain",
256                S_TRUST,
257                Ok(format!(
258                    "{keys} key{} trusted, each link verified",
259                    if keys == 1 { "" } else { "s" }
260                )),
261            );
262            // config.delegations (#2316): every delegation and revocation
263            // verifies against the chain.
264            let result = served.and_then(|config| {
265                let first = fingerprint(&config.keys[0].key).unwrap_or_default();
266                config
267                    .chain_from(&first)
268                    .ok_or("the key chain doesn't verify from its first key")?;
269                // Judged as a client pinned to the newest key sees it: the
270                // strictest view, since every anchor reaches the newest key.
271                let newest = config.keys.last().expect("parse checks").key.clone();
272                let signers = config.signers(&[newest]);
273                let detail = format!(
274                    "{} delegation(s), {} revocation(s), each signed by the newest chain key",
275                    config.delegations.len(),
276                    config.revoked.len()
277                );
278                let ignored = signers.ignored.clone();
279                run.config = Some((config, signers));
280                if ignored.is_empty() {
281                    Ok(detail)
282                } else {
283                    Err(format!("`config.json` has {}", ignored.join("; ")))
284                }
285            });
286            run.record("config.delegations", S_DELEGATION, result);
287            api
288        }
289        Err(e) => {
290            let chain = e.code == super::KEY_MISMATCH || e.code == super::BAD_SIGNATURE;
291            if chain {
292                run.record("config.shape", S_CONFIG, Ok("`config.json` parses".into()));
293                run.record("config.chain", S_TRUST, Err(e.to_string()));
294            } else {
295                run.record("config.shape", S_CONFIG, Err(e.to_string()));
296                run.skip("config.chain", S_TRUST, "`config.json` couldn't be read");
297            }
298            if !opts.read_only {
299                run.skip_rest("`config.json` didn't verify");
300            }
301            return;
302        }
303    };
304
305    if opts.read_only {
306        read_only(run, &mut reader);
307        return;
308    }
309    let Some(api) = api else {
310        run.record(
311            "publish.api",
312            S_PUBLISH,
313            Err("`config.json` has no `api`, so the registry takes no publishes over HTTP — run with `--read-only`".into()),
314        );
315        run.skip_rest("the registry has no publish API");
316        return;
317    };
318    let Some(token) = token else {
319        run.record(
320            "publish.token",
321            S_PUBLISH,
322            Err(format!(
323                "no publish token: set `{}`",
324                bynk_project::manifest::registry_token_var(&opts.name)
325            )),
326        );
327        run.skip_rest("no publish token");
328        return;
329    };
330    publishing(run, &mut reader, &api, &token);
331}
332
333/// `--read-only`: the lines of an existing package verify, its newest
334/// version's archive (that isn't withdrawn) is the signed tree, and its
335/// withdrawals are consistent.
336fn read_only(run: &mut Run<'_>, reader: &mut Client) {
337    let name = run.opts.name.as_str();
338    let Some(package) = run.opts.package.clone() else {
339        run.skip("read.lines", S_INDEX, "no `--package` given to read back");
340        run.skip("read.archive", S_ARCHIVES, "no `--package` given");
341        run.skip("read.revoked", S_DELEGATION, "no `--package` given");
342        run.skip("read.withdrawn", S_WITHDRAWALS, "no `--package` given");
343        return;
344    };
345    match reader.lines(name, &package) {
346        Ok(lines) if !lines.is_empty() => {
347            run.record(
348                "read.lines",
349                S_INDEX,
350                Ok(format!("{} line(s) of `{package}` verify", lines.len())),
351            );
352            // #2330: a withdrawn version has no archive to fetch.
353            match lines.iter().rev().find(|l| l.withdrawn.is_none()).cloned() {
354                Some(last) => {
355                    let home = run.work.join("home");
356                    let result = reader
357                        .fetch(name, &last, &home)
358                        .map(|_| format!("{} {} is the signed tree", package, last.version))
359                        .map_err(|e| e.to_string());
360                    run.record("read.archive", S_ARCHIVES, result);
361                }
362                None => run.skip("read.archive", S_ARCHIVES, "every version is withdrawn"),
363            }
364        }
365        Ok(_) => {
366            run.record(
367                "read.lines",
368                S_INDEX,
369                Err(format!("`{package}` has no lines")),
370            );
371            run.skip("read.archive", S_ARCHIVES, "no line to fetch");
372        }
373        Err(e) => {
374            run.record("read.lines", S_INDEX, Err(e.to_string()));
375            run.skip("read.archive", S_ARCHIVES, "the index didn't verify");
376        }
377    }
378    // read.revoked (#2316): judged on the index as served, independently of
379    // the client's own verdict, so a revoked signer fails this check by name.
380    let index = reader
381        .open(name)
382        .map(|o| o.index.clone())
383        .unwrap_or_default();
384    let revoked = match (&run.config, get_index(&transport::join(&index, &package))) {
385        (Some((config, signers)), Ok(bytes)) => {
386            let text = String::from_utf8_lossy(&bytes).into_owned();
387            let mut found = Vec::new();
388            for raw in text.lines().filter(|l| !l.trim().is_empty()) {
389                match IndexLine::parse(raw) {
390                    Ok(line) => {
391                        let payload = line.payload(&config.id);
392                        if let Some(k) = signers
393                            .revoked
394                            .iter()
395                            .find(|k| verify(k, &payload, &line.sig))
396                        {
397                            found.push(format!(
398                                "{} {} is signed by the revoked key {k}",
399                                line.name, line.version
400                            ));
401                        }
402                    }
403                    Err(e) => found.push(e),
404                }
405            }
406            if found.is_empty() {
407                Ok("no line is signed by a revoked key".to_string())
408            } else {
409                Err(found.join("; "))
410            }
411        }
412        (None, _) => Err("the served `config.json` couldn't be read".to_string()),
413        (_, Err(e)) => Err(e),
414    };
415    run.record("read.revoked", S_DELEGATION, revoked);
416    let withdrawn = withdrawals(reader, name, &index, &package);
417    run.record("read.withdrawn", S_WITHDRAWALS, withdrawn);
418}
419
420/// #2330 (F): `read.withdrawn` — every withdrawn line of `package`, as served,
421/// is also yanked, and its archive is no longer served (`404`) unless a live
422/// version of the package names the same tree. A tree shared with another
423/// package is beyond what a one-package check can see.
424fn withdrawals(
425    reader: &mut Client,
426    name: &str,
427    index: &str,
428    package: &str,
429) -> Result<String, String> {
430    let bytes = get_index(&transport::join(index, package))?;
431    let text = String::from_utf8_lossy(&bytes).into_owned();
432    let lines = text
433        .lines()
434        .filter(|l| !l.trim().is_empty())
435        .map(IndexLine::parse)
436        .collect::<Result<Vec<_>, _>>()?;
437    // A tree another, live version still names keeps its archive (`bynk
438    // registry withdraw` keeps a shared one), so it may still be served.
439    let live: std::collections::BTreeSet<&str> = lines
440        .iter()
441        .filter(|l| l.withdrawn.is_none())
442        .map(|l| l.checksum.as_str())
443        .collect();
444    let opened = reader.open(name).map_err(|e| e.to_string())?;
445    let mut problems = Vec::new();
446    let mut count = 0;
447    for line in lines.iter().filter(|l| l.withdrawn.is_some()) {
448        count += 1;
449        if !line.yanked {
450            problems.push(format!(
451                "{} {} is withdrawn but not yanked",
452                line.name, line.version
453            ));
454        }
455        if live.contains(line.checksum.as_str()) {
456            continue;
457        }
458        let hex = bynk_project::lock::checksum_hex(&line.checksum)
459            .ok_or_else(|| format!("{} {}'s checksum is malformed", line.name, line.version))?;
460        let url = transport::join(&opened.dl, &format!("sha256/{hex}"));
461        match opened.transport.get(&url) {
462            Ok(None) => {}
463            Ok(Some(_)) => problems.push(format!(
464                "{} {} is withdrawn, but its archive `{url}` is still served (and no live version of `{package}` names its tree)",
465                line.name, line.version
466            )),
467            Err(e) => problems.push(e.to_string()),
468        }
469    }
470    if !problems.is_empty() {
471        Err(problems.join("; "))
472    } else if count == 0 {
473        Ok(format!("`{package}` has no withdrawn versions"))
474    } else {
475        Ok(format!(
476            "{count} withdrawn version(s) of `{package}`, each yanked, its archive gone"
477        ))
478    }
479}
480
481/// A fixture package `name` at `version`, its commons returning `n`, with
482/// extra manifest text; packed. `(archive, checksum)`.
483fn fixture(
484    work: &Path,
485    name: &str,
486    version: &str,
487    n: u32,
488    extra: &str,
489) -> Result<(Vec<u8>, String), String> {
490    let dir = work.join(format!("{}-{version}-{n}", name.replace('/', "_")));
491    let _ = std::fs::remove_dir_all(&dir);
492    std::fs::create_dir_all(dir.join("src")).map_err(|e| e.to_string())?;
493    std::fs::write(
494        dir.join("bynk.toml"),
495        format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n{extra}"),
496    )
497    .map_err(|e| e.to_string())?;
498    std::fs::write(
499        dir.join("src/probe.bynk"),
500        format!("commons probe\n\nfn value() -> Int {{\n  {n}\n}}\n"),
501    )
502    .map_err(|e| e.to_string())?;
503    archive::pack(&dir).map_err(|e| format!("could not pack a fixture: {e}"))
504}
505
506/// A `refuse.*` check: its id, the API path, and the upload (or why it
507/// couldn't be built).
508type Refusal = (&'static str, String, Result<Vec<u8>, String>);
509
510/// The refusals seen, for the `errors.shape` check: `(check, status, content
511/// type, body, expected code)`.
512type Seen = Vec<(&'static str, u16, Option<String>, Vec<u8>, &'static str)>;
513
514fn publishing(run: &mut Run<'_>, reader: &mut Client, api: &str, token: &str) {
515    let opts = run.opts;
516    let name = opts.name.as_str();
517    let org = opts.org.clone();
518    let package = format!("{org}/conformance_{}_{}", utc_stamp(), hex4());
519    let url = |rest: &str| transport::join(api, rest);
520    let as_token = Auth::Bearer(token);
521    let mut seen: Seen = Vec::new();
522
523    let Ok((first, first_sum)) = fixture(&run.work, &package, "0.0.1", 1, "") else {
524        run.record(
525            "publish.created",
526            S_PUBLISH,
527            Err("could not build the fixture package".into()),
528        );
529        run.skip_rest("the fixture package couldn't be built");
530        return;
531    };
532
533    // publish.created
534    let created = transport::request(
535        "PUT",
536        &url(&format!("{package}/0.0.1")),
537        Body::Bytes(&first),
538        Auth::Bearer(token),
539    );
540    let created_line = match &created {
541        Ok((201, _, body)) => match parse_line(body) {
542            Ok(line) => {
543                run.report.published = Some(package.clone());
544                run.record(
545                    "publish.created",
546                    S_PUBLISH,
547                    Ok(format!("201 with the signed line for {package} 0.0.1")),
548                );
549                Some(line)
550            }
551            Err(e) => {
552                run.record(
553                    "publish.created",
554                    S_PUBLISH,
555                    Err(format!("201, but the body isn't an index line: {e}")),
556                );
557                None
558            }
559        },
560        Ok((status, _, body)) => {
561            run.record(
562                "publish.created",
563                S_PUBLISH,
564                Err(format!("expected 201, got {status}: {}", snippet(body))),
565            );
566            None
567        }
568        Err(e) => {
569            run.record("publish.created", S_PUBLISH, Err(e.clone()));
570            None
571        }
572    };
573    if created_line.is_none() {
574        // A 2xx may still have created it: name it, and yank it (best effort),
575        // as decision B promises.
576        if matches!(&created, Ok((status, _, _)) if (200..300).contains(status)) {
577            run.report.published = Some(package.clone());
578            let _ = transport::request(
579                "POST",
580                &url(&format!("{package}/0.0.1/yank")),
581                Body::None,
582                Auth::Bearer(token),
583            );
584        }
585        run.skip_rest("`publish.created` failed");
586        return;
587    }
588
589    // publish.read-back: through the verifying client.
590    reader.forget(name, &package);
591    let read_back = reader
592        .lines(name, &package)
593        .map_err(|e| e.to_string())
594        .and_then(|lines| {
595            let line = lines
596                .iter()
597                .find(|l| l.version == "0.0.1")
598                .cloned()
599                .ok_or("the index doesn't list 0.0.1")?;
600            if line.checksum != first_sum {
601                return Err(format!(
602                    "the line's checksum is {}, but the tree sent is {first_sum}",
603                    line.checksum
604                ));
605            }
606            reader
607                .fetch(name, &line, &run.work.join("home"))
608                .map_err(|e| e.to_string())?;
609            Ok("the line verifies, its checksum is the tree sent, and the archive matches".into())
610        });
611    run.record("publish.read-back", S_INDEX, read_back);
612
613    // publish.signer (#2316): signed by the chain or a valid delegate, and
614    // not by a revoked one.
615    let signer = match (&run.config, &created_line) {
616        (Some((config, signers)), Some(line)) => signed_by(config, signers, line),
617        _ => Err("the served `config.json` couldn't be read".to_string()),
618    };
619    run.record("publish.signer", S_DELEGATION, signer);
620    let index_url = transport::join(&reader_index(reader, name), &package);
621    let index_before = get_index(&index_url);
622
623    // publish.retry-same-tree (D10)
624    let retry = transport::request(
625        "PUT",
626        &url(&format!("{package}/0.0.1")),
627        Body::Bytes(&first),
628        Auth::Bearer(token),
629    );
630    let result = match retry {
631        Ok((200, _, body)) => match (parse_line(&body), &created_line) {
632            (Ok(line), Some(orig)) if line == *orig => Ok("200 with the existing line".to_string()),
633            (Ok(_), _) => Err("200, but not with the existing line".to_string()),
634            (Err(e), _) => Err(format!("200, but the body isn't an index line: {e}")),
635        },
636        Ok((status, _, body)) => Err(format!(
637            "expected 200 with the existing line, got {status}: {}",
638            snippet(&body)
639        )),
640        Err(e) => Err(e),
641    };
642    run.record("publish.retry-same-tree", S_PUBLISH, result);
643
644    // publish.conflict
645    let conflict = fixture(&run.work, &package, "0.0.1", 2, "").and_then(|(other, _)| {
646        transport::request(
647            "PUT",
648            &url(&format!("{package}/0.0.1")),
649            Body::Bytes(&other),
650            Auth::Bearer(token),
651        )
652    });
653    let after = get_index(&index_url);
654    let result = expect_refusal(
655        &mut seen,
656        "publish.conflict",
657        conflict,
658        409,
659        "version_exists",
660    )
661    .and_then(|d| match (&index_before, &after) {
662        (Ok(before), Ok(after)) if before == after => Ok(format!("{d}; the index is unchanged")),
663        (Ok(_), Ok(_)) => Err("409, but the index changed".to_string()),
664        (Err(e), _) | (_, Err(e)) => Err(format!(
665            "{d}, but the index couldn't be read to confirm it is unchanged: {e}"
666        )),
667    });
668    run.record("publish.conflict", S_PUBLISH, result);
669
670    // refuse.*: each a 400 that leaves no line behind.
671    let other_name = format!("{package}x");
672    let junk_traversal = {
673        let mut evil = b"bynk-tree-v1\n../escape.txt\x005\x00hello".to_vec();
674        evil.extend_from_slice(b"bynk.toml\x000\x00");
675        evil
676    };
677    let path_dep = fixture(
678        &run.work,
679        &package,
680        "0.0.7",
681        7,
682        &format!(
683            "\n[dependencies]\nhelper = {{ package = \"{org}/helper\", path = \"../helper\" }}\n"
684        ),
685    );
686    let refusals: Vec<Refusal> = vec![
687        (
688            "refuse.other-package",
689            format!("{other_name}/0.0.1"),
690            Ok(first.clone()),
691        ),
692        (
693            "refuse.other-version",
694            format!("{package}/0.0.4"),
695            Ok(first.clone()),
696        ),
697        (
698            "refuse.junk",
699            format!("{package}/0.0.5"),
700            Ok(b"not an archive".to_vec()),
701        ),
702        (
703            "refuse.traversal",
704            format!("{package}/0.0.6"),
705            Ok(junk_traversal),
706        ),
707        (
708            "refuse.path-dependency",
709            format!("{package}/0.0.7"),
710            path_dep.map(|(b, _)| b),
711        ),
712    ];
713    for (id, path, body) in refusals {
714        let result = match body {
715            Ok(body) => {
716                let sent =
717                    transport::request("PUT", &url(&path), Body::Bytes(&body), Auth::Bearer(token));
718                expect_refusal(&mut seen, id, sent, 400, "invalid_package")
719            }
720            Err(e) => Err(format!("could not build the upload: {e}")),
721        };
722        run.record(id, S_PUBLISH, result);
723    }
724    reader.forget(name, &package);
725    let stray = reader
726        .lines(name, &package)
727        .map(|lines| {
728            lines
729                .iter()
730                .filter(|l| l.version != "0.0.1")
731                .map(|l| l.version.clone())
732                .collect::<Vec<_>>()
733        })
734        .map_err(|e| e.to_string());
735    // The upload to another package's URL must not have created it.
736    let other = match reader.lines(name, &other_name) {
737        Ok(lines) if lines.is_empty() => Ok(()),
738        Ok(_) => Err(format!("`{other_name}` gained a line")),
739        Err(e) if e.code == super::UNAVAILABLE => Ok(()),
740        Err(e) => Err(format!("`{other_name}`'s index couldn't be checked: {e}")),
741    };
742    let clean = match (stray, other) {
743        (Ok(v), Ok(())) if v.is_empty() => {
744            Ok("no refused upload left a line, here or under another package".to_string())
745        }
746        (Ok(v), Ok(())) => Err(format!("refused uploads left lines: {}", v.join(", "))),
747        (Err(e), _) | (_, Err(e)) => Err(e),
748    };
749    run.record("refuse.clean", S_PUBLISH, clean);
750
751    // auth.*
752    let auth_url = url(&format!("{package}/0.0.8"));
753    let sent = transport::request("PUT", &auth_url, Body::Bytes(&first), Auth::Without);
754    let r = expect_refusal(&mut seen, "auth.missing", sent, 401, "unauthorised");
755    run.record("auth.missing", S_PUBLISH, r);
756    let bogus = format!("bynk_{}{}", hex4(), "0".repeat(60));
757    let sent = transport::request("PUT", &auth_url, Body::Bytes(&first), Auth::Bearer(&bogus));
758    let r = expect_refusal(&mut seen, "auth.unknown", sent, 401, "unauthorised");
759    run.record("auth.unknown", S_PUBLISH, r);
760    match &opts.narrow_token {
761        Some(Err(var)) => run.record(
762            "auth.scope",
763            S_PUBLISH,
764            Err(format!(
765                "`--narrow-token-env {var}` was given, but `{var}` isn't set"
766            )),
767        ),
768        Some(Ok(narrow)) => {
769            let sent =
770                transport::request("PUT", &auth_url, Body::Bytes(&first), Auth::Bearer(narrow));
771            let r = expect_refusal(&mut seen, "auth.scope", sent, 403, "forbidden");
772            run.record("auth.scope", S_PUBLISH, r);
773        }
774        None => run.skip(
775            "auth.scope",
776            S_PUBLISH,
777            "no narrow token given (`--narrow-token-env`)",
778        ),
779    }
780
781    // limits.too-large
782    let sent = transport::request(
783        "PUT",
784        &url(&format!("{package}/0.0.9")),
785        Body::Zeros(opts.max_upload + 1),
786        as_token,
787    );
788    let r = expect_refusal(&mut seen, "limits.too-large", sent, 413, "too_large");
789    run.record("limits.too-large", S_PUBLISH, r);
790
791    // consume: a fresh client and store select and admit the version.
792    let consumer_home = run.work.join("consumer");
793    let mut consumer = client(opts);
794    let consumed = consumer
795        .lines(name, &package)
796        .map_err(|e| e.to_string())
797        .and_then(|lines| {
798            let v = semver::Version::parse(env!("CARGO_PKG_VERSION")).expect("our own version");
799            let line = super::select::choose(&lines, &["^0.0.1".to_string()], None, &v)
800                .map_err(|_| "no version could be chosen".to_string())?
801                .clone();
802            consumer
803                .fetch(name, &line, &consumer_home)
804                .map_err(|e| e.to_string())?;
805            Ok(format!(
806                "a fresh client chose {} and admitted its tree",
807                line.version
808            ))
809        });
810    run.record("consume.resolve", S_INDEX, consumed);
811
812    // yank.*
813    let yank = |verb: &str| {
814        transport::request(
815            "POST",
816            &url(&format!("{package}/0.0.1/{verb}")),
817            Body::None,
818            Auth::Bearer(token),
819        )
820    };
821    let mut flip = |run: &mut Run<'_>, id: &'static str, verb: &str, want: bool| {
822        let result = match yank(verb) {
823            Ok((200, _, _)) => {
824                reader.forget(name, &package);
825                match reader.lines(name, &package) {
826                    Ok(lines) => match lines.iter().find(|l| l.version == "0.0.1") {
827                        Some(l) if l.yanked == want => {
828                            Ok(format!("200, and the index shows yanked = {want}"))
829                        }
830                        Some(_) => Err(format!("200, but the index doesn't show yanked = {want}")),
831                        None => Err("the line disappeared".to_string()),
832                    },
833                    Err(e) => Err(e.to_string()),
834                }
835            }
836            Ok((status, _, body)) => Err(format!("expected 200, got {status}: {}", snippet(&body))),
837            Err(e) => Err(e),
838        };
839        run.record(id, S_PUBLISH, result);
840    };
841    flip(run, "yank.yank", "yank", true);
842    flip(run, "yank.unyank", "unyank", false);
843    let sent = transport::request(
844        "POST",
845        &url(&format!("{package}/9.9.9/yank")),
846        Body::None,
847        Auth::Bearer(token),
848    );
849    let r = expect_refusal(&mut seen, "yank.missing", sent, 404, "not_found");
850    run.record("yank.missing", S_PUBLISH, r);
851
852    // errors.shape: every refusal is JSON `{error, message}` with its code.
853    let mut bad = Vec::new();
854    for (id, _status, kind, body, code) in &seen {
855        let json = kind
856            .as_deref()
857            .is_some_and(|k| k.starts_with("application/json"));
858        let parsed: Option<(String, bool)> = serde_json::from_slice::<serde_json::Value>(body)
859            .ok()
860            .and_then(|v| {
861                Some((
862                    v.get("error")?.as_str()?.to_owned(),
863                    v.get("message")?.is_string(),
864                ))
865            });
866        match parsed {
867            Some((c, true)) if json && c == *code => {}
868            Some((c, _)) if c != *code => {
869                bad.push(format!("{id}: `error` is `{c}`, expected `{code}`"))
870            }
871            _ => bad.push(format!("{id}: not a JSON `{{error, message}}` body")),
872        }
873    }
874    let result = if seen.is_empty() {
875        Err("no refusal was observed".to_string())
876    } else if bad.is_empty() {
877        Ok(format!(
878            "{} refusals, each JSON with the right `error`",
879            seen.len()
880        ))
881    } else {
882        Err(bad.join("; "))
883    };
884    run.record("errors.shape", S_PUBLISH, result);
885
886    // Leave the throwaway package yanked (decision B).
887    let _ = yank("yank");
888}
889
890/// Check a response is the refusal `status` with `code`, recording it for
891/// `errors.shape`.
892fn expect_refusal(
893    seen: &mut Seen,
894    id: &'static str,
895    sent: Result<(u16, Option<String>, Vec<u8>), String>,
896    status: u16,
897    code: &'static str,
898) -> Result<String, String> {
899    match sent {
900        Ok((got, kind, body)) if got == status => {
901            seen.push((id, got, kind, body, code));
902            Ok(format!("{status} `{code}`"))
903        }
904        Ok((got, _, body)) => Err(format!(
905            "expected {status} `{code}`, got {got}: {}",
906            snippet(&body)
907        )),
908        Err(e) => Err(e),
909    }
910}
911
912/// The served `config.json`, parsed.
913fn raw_config(index: &str) -> Result<Config, String> {
914    let bytes = get_index(&transport::join(index, "config.json"))?;
915    Config::parse(&bytes)
916}
917
918/// Which kind of key signed `line`: a chain key or a valid delegate; an error
919/// when it is a revoked delegate, or none of them.
920fn signed_by(config: &Config, signers: &Signers, line: &IndexLine) -> Result<String, String> {
921    let payload = line.payload(&config.id);
922    if let Some(k) = signers
923        .revoked
924        .iter()
925        .find(|k| verify(k, &payload, &line.sig))
926    {
927        return Err(format!(
928            "{} {} is signed by the revoked key {k}",
929            line.name, line.version
930        ));
931    }
932    match signers.keys.iter().find(|k| verify(k, &payload, &line.sig)) {
933        Some(k) if config.keys.iter().any(|c| &c.key == k) => {
934            Ok(format!("signed by the chain key {k}"))
935        }
936        Some(k) => Ok(format!("signed by the delegate {k}")),
937        None => Err(format!(
938            "{} {} is signed by no key the registry trusts",
939            line.name, line.version
940        )),
941    }
942}
943
944/// An index file's bytes, or why it couldn't be read (anything but `200`).
945fn get_index(url: &str) -> Result<Vec<u8>, String> {
946    match transport::request("GET", url, Body::None, Auth::Without)? {
947        (200, _, body) => Ok(body),
948        (status, _, _) => Err(format!("`{url}` answered {status}")),
949    }
950}
951
952fn parse_line(body: &[u8]) -> Result<IndexLine, String> {
953    let text = std::str::from_utf8(body).map_err(|_| "not UTF-8".to_string())?;
954    IndexLine::parse(text.trim())
955}
956
957fn reader_index(reader: &mut Client, name: &str) -> String {
958    reader
959        .open(name)
960        .map(|o| o.index.clone())
961        .unwrap_or_default()
962}
963
964fn snippet(body: &[u8]) -> String {
965    let text = String::from_utf8_lossy(body);
966    let text = text.trim();
967    if text.is_empty() {
968        "(empty body)".to_string()
969    } else if text.chars().count() > 160 {
970        format!("{}…", text.chars().take(160).collect::<String>())
971    } else {
972        text.to_string()
973    }
974}
975
976fn scratch_dir() -> PathBuf {
977    loop {
978        let dir = std::env::temp_dir().join(format!("bynk-conformance-{}{}", hex4(), hex4()));
979        if std::fs::create_dir(&dir).is_ok() {
980            return dir;
981        }
982    }
983}
984
985fn hex4() -> String {
986    let mut raw = [0u8; 2];
987    let _ = ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut raw);
988    format!("{:02x}{:02x}", raw[0], raw[1])
989}
990
991/// The current UTC time as `yyyymmddhhmmss`.
992fn utc_stamp() -> String {
993    let secs = std::time::SystemTime::now()
994        .duration_since(std::time::UNIX_EPOCH)
995        .map_or(0, |d| d.as_secs());
996    let (days, rem) = (secs / 86_400, secs % 86_400);
997    // Howard Hinnant's civil-from-days.
998    let z = days as i64 + 719_468;
999    let era = z.div_euclid(146_097);
1000    let doe = z.rem_euclid(146_097);
1001    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1002    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1003    let mp = (5 * doy + 2) / 153;
1004    let d = doy - (153 * mp + 2) / 5 + 1;
1005    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1006    let y = yoe + era * 400 + i64::from(m <= 2);
1007    format!(
1008        "{y:04}{m:02}{d:02}{:02}{:02}{:02}",
1009        rem / 3_600,
1010        rem % 3_600 / 60,
1011        rem % 60
1012    )
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018
1019    #[test]
1020    fn the_stamp_is_fourteen_digits() {
1021        let s = utc_stamp();
1022        assert_eq!(s.len(), 14, "{s}");
1023        assert!(s.starts_with("20"), "{s}");
1024        assert!(s.chars().all(|c| c.is_ascii_digit()));
1025    }
1026}