server/lib/tests/mirror_declaration.rs
//! The mirror's DECLARATION and its CYCLE, end to end in one process.
//!
//! `server/lib/src/mirror.rs` shipped as an engine with no caller.
//! `mirror_cycle.rs` is the caller, and this file is the proof that the seven
//! things a wired mirror has to do are the seven things it does:
//!
//! 1. a first cycle takes everything and the bytes are byte-identical,
//! 2. a second cycle over an unchanged upstream moves nothing,
//! 3. an artifact ADDED upstream arrives on the next cycle — the property that
//! separates a mirror from a one-shot copy,
//! 4. an artifact whose bytes CHANGED is re-fetched,
//! 5. an `unproven` artifact is re-fetched by DEFAULT and skipped only under the
//! opt-in,
//! 6. the change map survives a restart, so a restarted mirror does not re-pull
//! the world,
//! 7. an artifact the mirror could not take is asked for again next cycle.
//!
//! The upstream here is a fake in memory. The LIVE proof against `holger.rs` is
//! the transcript in the lane report; this file is what makes a regression show
//! up in `cargo test` instead of on a box.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use holger_server_lib::mirror_cycle::{
coord_to_id, id_to_coord, run_pair, MirrorPair, MirrorSettings, MirrorUpstream, PairReport,
RonUnprovenPolicy,
};
use traits::{
ArtifactEntry, ArtifactFormat, ArtifactId, Bytes, RemoteAsset, RepositoryBackendTrait,
};
// ───────────────────────────── the fakes ─────────────────────────────
/// An upstream holding `coord -> bytes`, which publishes a digest only when
/// `digests` is true.
///
/// This one builds its [`RemoteAsset`]s directly, so it stands for "a source
/// that does / does not publish digests" in general. The holger-specific shape
/// — an `ArtifactEntry` page put through `holger_listing` — is
/// [`HolgerListingUpstream`] below, and it is a separate fake on purpose:
/// `digests: false` used to be described here as "the HOLGER shape", and that
/// is no longer what a holger listing is.
#[derive(Clone)]
struct FakeUpstream {
items: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
digests: bool,
/// Coordinates whose fetch fails, to exercise the carry-over rule.
broken: Arc<Mutex<Vec<String>>>,
fetches: Arc<Mutex<usize>>,
}
impl FakeUpstream {
fn new(digests: bool) -> Self {
Self {
items: Arc::new(Mutex::new(BTreeMap::new())),
digests,
broken: Arc::new(Mutex::new(Vec::new())),
fetches: Arc::new(Mutex::new(0)),
}
}
fn put(&self, coord: &str, bytes: &[u8]) {
self.items
.lock()
.unwrap()
.insert(coord.to_string(), bytes.to_vec());
}
fn remove(&self, coord: &str) {
self.items.lock().unwrap().remove(coord);
}
fn fetch_count(&self) -> usize {
*self.fetches.lock().unwrap()
}
}
#[async_trait::async_trait]
impl MirrorUpstream for FakeUpstream {
async fn list(&self, _repository: &str, _page_size: u32) -> Result<Vec<RemoteAsset>> {
let items = self.items.lock().unwrap();
Ok(items
.iter()
.map(|(coord, bytes)| RemoteAsset {
path: coord.clone(),
download_url: format!("fake://{coord}"),
content_type: None,
size: Some(bytes.len() as u64),
sha256: self.digests.then(|| nornir_hash::sha256_hex(bytes)),
change_token: None,
})
.collect())
}
async fn fetch(&self, _repository: &str, id: &ArtifactId) -> Result<Option<traits::Bytes>> {
*self.fetches.lock().unwrap() += 1;
let coord = id_to_coord(id);
if self.broken.lock().unwrap().contains(&coord) {
anyhow::bail!("fake upstream: {coord} is broken");
}
Ok(self
.items
.lock()
.unwrap()
.get(&coord)
.cloned()
.map(traits::Bytes::from))
}
}
/// **A holger upstream, listed the way a holger upstream is actually listed.**
///
/// The point of this fake is that it does not hand `RemoteAsset`s to the engine:
/// it builds `traits::ArtifactEntry` rows the way a repository backend does and
/// puts them through the real
/// [`holger_server_lib::mirror_cycle::holger_listing`], which is the function
/// that used to fill `sha256: None` unconditionally. So a regression in that
/// conversion — dropping the digest, or inventing one — shows up here as a
/// verdict changing, not as a comment going stale.
///
/// `backend_can_prove` is the one knob, and it stands for the real split
/// between holger's own backends: `file-oci` (and a warm `file-rust`) can put a
/// digest in a listing without reading the store; `raw` and every `znippy-*`
/// cannot, and answer `None`.
#[derive(Clone)]
struct HolgerListingUpstream {
items: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
backend_can_prove: bool,
fetches: Arc<Mutex<usize>>,
}
impl HolgerListingUpstream {
fn new(backend_can_prove: bool) -> Self {
Self {
items: Arc::new(Mutex::new(BTreeMap::new())),
backend_can_prove,
fetches: Arc::new(Mutex::new(0)),
}
}
fn put(&self, coord: &str, bytes: &[u8]) {
self.items
.lock()
.unwrap()
.insert(coord.to_string(), bytes.to_vec());
}
fn fetch_count(&self) -> usize {
*self.fetches.lock().unwrap()
}
}
#[async_trait::async_trait]
impl MirrorUpstream for HolgerListingUpstream {
async fn list(&self, repository: &str, _page_size: u32) -> Result<Vec<RemoteAsset>> {
let entries: Vec<ArtifactEntry> = self
.items
.lock()
.unwrap()
.iter()
.filter_map(|(coord, bytes)| {
let id = coord_to_id(coord)?;
let e = ArtifactEntry::unknown_provenance(
id,
bytes.len() as i64,
"application/octet-stream",
);
Some(if self.backend_can_prove {
e.with_sha256(Some(nornir_hash::sha256_hex(bytes)))
} else {
e
})
})
.collect();
// THE REAL CONVERSION, not a hand-built RemoteAsset.
Ok(holger_server_lib::mirror_cycle::holger_listing(
"http://up:50051",
repository,
entries,
))
}
async fn fetch(&self, _repository: &str, id: &ArtifactId) -> Result<Option<traits::Bytes>> {
*self.fetches.lock().unwrap() += 1;
Ok(self
.items
.lock()
.unwrap()
.get(&id_to_coord(id))
.cloned()
.map(traits::Bytes::from))
}
}
/// A writable in-memory repository, so the cycle's `put` is real without a disk.
#[derive(Default)]
struct MemBackend {
held: Mutex<BTreeMap<String, Vec<u8>>>,
}
impl RepositoryBackendTrait for MemBackend {
fn caps(&self) -> traits::Caps {
traits::Caps {
listing: true,
..traits::Caps::NONE
}
}
fn name(&self) -> &str {
"mem"
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Raw
}
fn is_writable(&self) -> bool {
true
}
fn fetch(&self, id: &ArtifactId) -> Result<Option<Bytes>> {
Ok(self
.held
.lock()
.unwrap()
.get(&id_to_coord(id))
.map(|v| Bytes::from(v.clone())))
}
fn put(&self, id: &ArtifactId, data: &[u8]) -> Result<()> {
self.held
.lock()
.unwrap()
.insert(id_to_coord(id), data.to_vec());
Ok(())
}
fn handle_http2_request(
&self,
_method: &str,
_path: &str,
_body: &[u8],
) -> Result<(u16, Vec<(String, String)>, Vec<u8>)> {
anyhow::bail!("the mirror never goes through the HTTP door")
}
fn list(&self, _f: Option<&str>, _l: usize) -> Result<Vec<ArtifactEntry>> {
Ok(self
.held
.lock()
.unwrap()
.iter()
.filter_map(|(c, v)| {
coord_to_id(c).map(|id| ArtifactEntry {
id,
size_bytes: v.len() as i64,
content_type: String::new(),
// The mirror DESTINATION: an in-memory store that knows
// neither a publication time nor a digest, and says so.
..ArtifactEntry::UNKNOWN_PROVENANCE
})
})
.collect())
}
}
// ───────────────────────────── the harness ─────────────────────────────
struct Bench {
up: FakeUpstream,
local: Arc<dyn RepositoryBackendTrait>,
mem: Arc<MemBackend>,
dir: tempfile::TempDir,
}
fn bench(digests: bool) -> Bench {
let mem = Arc::new(MemBackend::default());
Bench {
up: FakeUpstream::new(digests),
local: mem.clone() as Arc<dyn RepositoryBackendTrait>,
mem,
dir: tempfile::tempdir().expect("tempdir"),
}
}
impl Bench {
async fn cycle(&self, policy: RonUnprovenPolicy) -> PairReport {
let pair = MirrorPair {
ron_from: "crates".into(),
ron_to: "crates".into(),
ron_unproven: policy,
};
run_pair(
&self.up,
"up",
&pair,
&self.local,
self.dir.path(),
1000,
4,
holger_server_lib::mirror_cycle::now_nanos(),
)
.await
.expect("cycle")
}
fn local_bytes(&self, coord: &str) -> Option<Vec<u8>> {
self.mem.held.lock().unwrap().get(coord).cloned()
}
}
// ───────────────────────────── the proofs ─────────────────────────────
#[tokio::test]
async fn a_first_cycle_takes_everything_and_the_bytes_are_identical() {
let b = bench(true);
b.up.put("/alpha@1.0", b"alpha-bytes");
b.up.put("/beta@2.0", b"beta-bytes-longer");
let r = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(r.listed, 2);
assert_eq!(
r.map_before, 0,
"a mirror that has never synced holds nothing"
);
assert_eq!(r.added, 2);
assert_eq!(r.to_fetch, 2);
assert_eq!(r.fetched, 2);
assert_eq!(r.failed, 0);
assert_eq!(r.map_after, 2);
assert_eq!(
b.local_bytes("/alpha@1.0").as_deref(),
Some(&b"alpha-bytes"[..])
);
assert_eq!(
nornir_hash::sha256_hex(&b.local_bytes("/beta@2.0").unwrap()),
nornir_hash::sha256_hex(b"beta-bytes-longer"),
"byte-identical, proved by digest"
);
}
#[tokio::test]
async fn a_second_cycle_over_a_proved_unchanged_upstream_moves_nothing() {
let b = bench(/* the upstream publishes digests */ true);
b.up.put("/alpha@1.0", b"alpha-bytes");
b.up.put("/beta@2.0", b"beta-bytes");
let first = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(first.fetched, 2);
let second = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(second.map_before, 2, "the map carried across the cycle");
assert_eq!(
second.unchanged, 2,
"both digests matched — PROVED identical"
);
assert_eq!(second.unproven, 0);
assert_eq!(second.added, 0);
assert_eq!(second.changed, 0);
assert_eq!(second.to_fetch, 0);
assert!(second.moved_nothing(), "{}", second.line());
assert_eq!(b.up.fetch_count(), 2, "the upstream was never asked again");
}
/// Run one cycle against an arbitrary upstream, into a fresh in-memory local.
async fn cycle_against(
up: &dyn MirrorUpstream,
local: &Arc<dyn RepositoryBackendTrait>,
state: &std::path::Path,
policy: RonUnprovenPolicy,
) -> PairReport {
let pair = MirrorPair {
ron_from: "crates".into(),
ron_to: "crates".into(),
ron_unproven: policy,
};
run_pair(
up,
"up",
&pair,
local,
state,
1000,
4,
holger_server_lib::mirror_cycle::now_nanos(),
)
.await
.expect("cycle")
}
/// **★ A holger → holger mirror REACHES `unchanged`, with no policy written.**
///
/// This is the defect this lane exists for, stated as a cycle. Before the
/// digest field there was no way for the second cycle to prove sameness, so
/// every size-unchanged coordinate landed in `unproven` forever and the default
/// `Refetch` re-pulled the whole repository on every tick, for ever.
///
/// The upstream here lists through the real `holger_listing`, and the policy is
/// the DEFAULT — `Refetch` — precisely to show the policy is not what saved it.
#[tokio::test]
async fn a_holger_upstream_whose_backend_can_prove_reaches_unchanged_on_the_default_policy() {
let up = HolgerListingUpstream::new(/* backend_can_prove */ true);
up.put("/alpha@1.0", b"alpha-bytes");
up.put("/beta@2.0", b"beta-bytes");
let mem = Arc::new(MemBackend::default());
let local = mem.clone() as Arc<dyn RepositoryBackendTrait>;
let dir = tempfile::tempdir().expect("tempdir");
let first = cycle_against(&up, &local, dir.path(), RonUnprovenPolicy::Refetch).await;
assert_eq!(first.added, 2);
assert_eq!(first.fetched, 2);
let second = cycle_against(&up, &local, dir.path(), RonUnprovenPolicy::Refetch).await;
assert_eq!(
second.unchanged,
2,
"a holger listing that carries a digest must reach `unchanged`: {}",
second.line()
);
assert_eq!(
second.unproven,
0,
"nothing is left in doubt: {}",
second.line()
);
assert_eq!(second.to_fetch, 0);
assert!(second.moved_nothing(), "{}", second.line());
assert_eq!(up.fetch_count(), 2, "the upstream was never asked again");
}
/// The other half of the same truth: an upstream repository served by a backend
/// that CANNOT prove sameness cheaply — `raw`, every `znippy-*` — still lands in
/// `unproven`, and `ron_unproven` still decides. An old upstream, which sends
/// neither field, behaves identically; that is what proto3 explicit presence
/// buys.
#[tokio::test]
async fn a_holger_upstream_whose_backend_cannot_prove_is_still_unproven() {
let up = HolgerListingUpstream::new(/* backend_can_prove */ false);
up.put("/alpha@1.0", b"alpha-bytes");
let mem = Arc::new(MemBackend::default());
let local = mem.clone() as Arc<dyn RepositoryBackendTrait>;
let dir = tempfile::tempdir().expect("tempdir");
cycle_against(&up, &local, dir.path(), RonUnprovenPolicy::Refetch).await;
let second = cycle_against(&up, &local, dir.path(), RonUnprovenPolicy::Refetch).await;
assert_eq!(
second.unchanged,
0,
"equal size with NO digest is still not a proof of sameness: {}",
second.line()
);
assert_eq!(second.unproven, 1, "{}", second.line());
assert_eq!(second.to_fetch, 1, "the default still re-fetches the doubt");
}
#[tokio::test]
async fn a_source_that_publishes_no_digest_at_all_is_refetched_by_default() {
// A source of any kind whose listing carries no sha256.
let b = bench(false);
b.up.put("/alpha@1.0", b"alpha-bytes");
b.up.put("/beta@2.0", b"beta-bytes");
let first = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(first.added, 2);
let second = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(
second.unchanged, 0,
"equal size with NO digest is not a proof of sameness and must never be counted as one"
);
assert_eq!(
second.unproven,
2,
"it is its own bucket: {}",
second.line()
);
assert_eq!(
second.to_fetch, 2,
"the DEFAULT re-fetches the unproven — bandwidth is cheaper than a stale mirror"
);
assert_eq!(second.fetched, 2);
}
#[tokio::test]
async fn trust_size_skips_the_unproven_and_still_counts_it() {
let b = bench(false);
b.up.put("/alpha@1.0", b"alpha-bytes");
b.up.put("/beta@2.0", b"beta-bytes");
b.cycle(RonUnprovenPolicy::TrustSize).await;
let before = b.up.fetch_count();
let second = b.cycle(RonUnprovenPolicy::TrustSize).await;
assert_eq!(
second.unproven, 2,
"the policy decides the FETCH, never the admission — the doubt stays visible"
);
assert_eq!(second.to_fetch, 0);
assert!(second.moved_nothing(), "{}", second.line());
assert_eq!(b.up.fetch_count(), before, "nothing was asked for");
}
#[tokio::test]
async fn an_artifact_added_upstream_arrives_on_the_next_cycle() {
// THE property that separates a mirror from a one-shot copy.
let b = bench(true);
b.up.put("/alpha@1.0", b"alpha-bytes");
let first = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(first.fetched, 1);
assert_eq!(b.local_bytes("/gamma@3.0"), None);
b.up.put("/gamma@3.0", b"gamma-bytes");
let second = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(second.added, 1, "{}", second.line());
assert_eq!(second.unchanged, 1);
assert_eq!(second.fetched, 1, "ONLY the new one moved");
assert_eq!(
b.local_bytes("/gamma@3.0").as_deref(),
Some(&b"gamma-bytes"[..])
);
assert_eq!(second.map_after, 2);
}
#[tokio::test]
async fn an_artifact_whose_bytes_changed_upstream_is_refetched() {
let b = bench(true);
b.up.put("/alpha@1.0", b"alpha-bytes");
b.cycle(RonUnprovenPolicy::Refetch).await;
// A DIFFERENT length: proved changed by size alone, and by digest too.
b.up.put("/alpha@1.0", b"alpha-bytes-v2");
let second = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(second.changed, 1, "{}", second.line());
assert_eq!(second.unchanged, 0);
assert_eq!(second.fetched, 1);
assert_eq!(
b.local_bytes("/alpha@1.0").as_deref(),
Some(&b"alpha-bytes-v2"[..]),
"the local copy converged on the upstream"
);
}
#[tokio::test]
async fn a_same_size_edit_is_caught_under_the_default_and_missed_under_trust_size() {
// This is the whole reason `unproven` is not folded into `unchanged`, and the
// whole reason TrustSize is a word an operator has to write.
for (policy, expect_caught) in [
(RonUnprovenPolicy::Refetch, true),
(RonUnprovenPolicy::TrustSize, false),
] {
let b = bench(/* no digests anywhere */ false);
b.up.put("/alpha@1.0", b"AAAAAAAA");
b.cycle(policy).await;
assert_eq!(
b.local_bytes("/alpha@1.0").as_deref(),
Some(&b"AAAAAAAA"[..])
);
// Same length, different bytes. `size` cannot see this.
b.up.put("/alpha@1.0", b"BBBBBBBB");
let second = b.cycle(policy).await;
assert_eq!(second.unproven, 1);
let caught = b.local_bytes("/alpha@1.0").as_deref() == Some(&b"BBBBBBBB"[..]);
assert_eq!(
caught, expect_caught,
"{policy:?}: a same-size edit is caught by Refetch and missed by TrustSize — \
that is the trade the operator opted into"
);
}
}
#[tokio::test]
async fn an_upstream_that_dropped_a_coordinate_reports_it_removed() {
let b = bench(true);
b.up.put("/alpha@1.0", b"a");
b.up.put("/beta@2.0", b"b");
b.cycle(RonUnprovenPolicy::Refetch).await;
b.up.remove("/beta@2.0");
let second = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(second.removed, 1, "{}", second.line());
assert_eq!(second.map_after, 1);
assert!(
b.local_bytes("/beta@2.0").is_some(),
"a mirror does not delete local bytes because an upstream stopped listing them — \
retention is a separate, deliberate decision"
);
}
#[tokio::test]
async fn a_fetch_that_failed_is_asked_for_again_next_cycle() {
let b = bench(true);
b.up.put("/alpha@1.0", b"a");
b.up.put("/beta@2.0", b"b");
b.up.broken.lock().unwrap().push("/beta@2.0".to_string());
let first = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(first.fetched, 1);
assert_eq!(first.failed, 1);
assert_eq!(
first.map_after, 1,
"the map NEVER claims a coordinate whose bytes the mirror does not hold"
);
b.up.broken.lock().unwrap().clear();
let second = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(
second.added,
1,
"still unknown to the map: {}",
second.line()
);
assert_eq!(second.fetched, 1);
assert_eq!(b.local_bytes("/beta@2.0").as_deref(), Some(&b"b"[..]));
}
#[tokio::test]
async fn the_change_map_survives_a_restart() {
// A "restart" here is the only thing a restart IS to this engine: a brand new
// in-memory map, loaded from the same state dir.
let b = bench(true);
for i in 0..50 {
b.up.put(&format!("/pkg{i:02}@1.0"), format!("body-{i}").as_bytes());
}
let first = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(first.fetched, 50);
let after_first = b.up.fetch_count();
// Same state dir, same upstream, same local backend: exactly what comes back
// up after `systemctl restart holger`.
let restarted = b.cycle(RonUnprovenPolicy::Refetch).await;
assert_eq!(
restarted.map_before,
50,
"the map was READ from disk, not rebuilt: {}",
restarted.line()
);
assert_eq!(restarted.unchanged, 50);
assert_eq!(restarted.to_fetch, 0);
assert_eq!(
b.up.fetch_count(),
after_first,
"a restarted mirror re-pulls NOTHING"
);
// And the file is where the declaration says it is.
let f = holger_server_lib::mirror::ChangeMap::path_in(b.dir.path(), "up", "crates");
assert!(f.exists(), "{} should hold the persisted map", f.display());
let reloaded = holger_server_lib::mirror::ChangeMap::read_file(&f).expect("reload");
assert_eq!(reloaded.len(), 50);
assert_eq!(reloaded.upstream(), "up");
assert_eq!(reloaded.repository(), "crates");
}
// ───────────────────── the declaration, as a config ─────────────────────
#[test]
fn a_config_written_before_the_key_existed_mirrors_nothing() {
// Byte-for-byte the shape that was deployed before this lane. It must still
// parse, and it must describe a holger that takes nothing from anywhere.
let older = r#"(
repositories: [
( ron_name: "bundles", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: None, ron_data_dir: Some("/tmp/x") ),
],
exposed_endpoints: [ (ron_name: "doors", ron_url: "127.0.0.1:0", ron_http_url: None) ],
storage_endpoints: [ (ron_name: "store", ron_storage_type: "znippy", ron_path: "/tmp/x") ],
)"#;
let h: holger_server_lib::Holger = ron::from_str(older).expect("an older config still parses");
assert!(
h.mirror.is_none(),
"absent means NO mirror — a config cannot acquire an upstream by being old"
);
}
#[test]
fn the_block_parses_on_the_root_config_and_a_typo_in_it_is_a_boot_error() {
let with_mirror = r#"(
repositories: [
( ron_name: "crates", ron_repo_type: "rust", ron_upstreams: [], ron_in: None,
ron_out: None, ron_data_dir: Some("/tmp/x") ),
],
exposed_endpoints: [ (ron_name: "doors", ron_url: "127.0.0.1:0", ron_http_url: None) ],
storage_endpoints: [ (ron_name: "store", ron_storage_type: "znippy", ron_path: "/tmp/x") ],
mirror: Some((
ron_state_dir: "holger-mirror",
ron_sources: [
(
ron_name: "holger-rs",
ron_kind: Holger,
ron_endpoint: "http://holger.rs:50051",
ron_credential: Anonymous,
ron_every_secs: 300,
ron_repositories: [ ( ron_from: "crates", ron_to: "crates" ) ],
),
],
)),
)"#;
let h: holger_server_lib::Holger = ron::from_str(with_mirror).expect("parse");
let m = h.mirror.expect("declared");
assert_eq!(m.ron_sources.len(), 1);
assert_eq!(
m.ron_sources[0].ron_repositories[0].ron_unproven,
RonUnprovenPolicy::Refetch
);
// The whole point of deny_unknown_fields: a mirror that reads as configured
// and runs as absent is the failure this refuses.
let typo = with_mirror.replace("ron_every_secs", "ron_every_sec");
assert!(
ron::from_str::<holger_server_lib::Holger>(&typo).is_err(),
"ron_every_sec must be a boot error, not a silently defaulted cadence"
);
}
#[test]
fn validate_is_what_the_boot_calls_and_it_refuses_by_name() {
let s: MirrorSettings = ron::from_str(
r#"( ron_sources: [ ( ron_name: "up", ron_kind: Holger,
ron_endpoint: "http://holger.rs:50051",
ron_repositories: [ ( ron_from: "crates", ron_to: "absent" ) ] ) ] )"#,
)
.expect("parse");
let e = format!("{}", s.validate(&[("crates".into(), true)]).unwrap_err());
assert!(e.contains("absent"), "{e}");
}