1use 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
24pub const NONCONFORMANT: &str = "bynk.registry.nonconformant";
26
27#[derive(Debug, Clone)]
29pub struct Options {
30 pub index: String,
32 pub org: String,
34 pub name: String,
36 pub key: Option<String>,
38 pub read_only: bool,
40 pub package: Option<String>,
42 pub narrow_token: Option<Result<String, String>>,
45 pub max_upload: u64,
47}
48
49#[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
67pub 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#[derive(Debug, Clone)]
97pub struct Check {
98 pub id: &'static str,
100 pub outcome: Outcome,
101 pub detail: String,
102 pub spec: &'static str,
104}
105
106#[derive(Debug, Clone, Default)]
108pub struct Report {
109 pub registry_id: Option<String>,
111 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 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 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 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
202pub 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 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 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 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
333fn 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 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 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
420fn 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 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
481fn 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
506type Refusal = (&'static str, String, Result<Vec<u8>, String>);
509
510type 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let _ = yank("yank");
888}
889
890fn 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
912fn raw_config(index: &str) -> Result<Config, String> {
914 let bytes = get_index(&transport::join(index, "config.json"))?;
915 Config::parse(&bytes)
916}
917
918fn 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
944fn 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
991fn 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 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}