server/lib/src/pull_intake/manifest_job.rs
//! **The manifests of a pull, read in a job** (lane HOL-312-HARDEN, SEC-REVIEW 2026-11-01 D1/D3,
//! row SEC-D3-SANDBOX-JOB): a POM, a `maven-metadata.xml` and a wheel's core metadata are bytes
//! an upstream wrote, so the server never parses them. It hands the bytes to a job of its ONE
//! runner ([`crate::job_runner::Jobs`]) and believes only the job's signed, structured answer:
//!
//! | Program | In | Out ([`RESULT_FILE`]) |
//! |---|---|---|
//! | [`MAVEN_CLOSURE`] | [`INDEX_FILE`] ([`ClosureInput`]) and the POMs and metadata it names | [`ClosureAnswer`]: the files still needed, or the closure |
//! | [`MAVEN_METADATA`] | [`METADATA_XML`] | [`MetadataAnswer`]: the newest release, a group's plugins |
//! | [`WHEEL_REQUIRES`] | [`METADATA_FILE`] (PEP 658) or [`WHEEL_FILE`] | [`WheelAnswer`]: the `Requires-Dist` that hold |
//!
//! A Maven closure is resolved in rounds: the job reads what it was given, names every POM and
//! metadata document its walk reached and was not given, and the server fetches those bytes
//! (nothing more) and runs the next round. The last round, with nothing missing, answers the
//! closure exactly as Maven 3.9 reads it ([`super::pom::closure`]).
use std::collections::{BTreeMap, BTreeSet};
use holger_job_runner::wire::{Answer, Dirs, WireJob};
use serde::{Deserialize, Serialize};
/// The Maven closure program.
pub const MAVEN_CLOSURE: &str = "maven-closure";
/// The `maven-metadata.xml` program.
pub const MAVEN_METADATA: &str = "maven-metadata";
/// The wheel core-metadata program.
pub const WHEEL_REQUIRES: &str = "wheel-requires";
/// The closure's index, in the input.
pub const INDEX_FILE: &str = "index.json";
/// The answer, in the output.
pub const RESULT_FILE: &str = "result.json";
/// A pulled wheel, in the input.
pub const WHEEL_FILE: &str = "wheel";
/// A wheel's PEP 658 core metadata, in the input.
pub const METADATA_FILE: &str = "metadata";
/// A `maven-metadata.xml`, in the input.
pub const METADATA_XML: &str = "maven-metadata.xml";
/// The most rounds one closure takes; past it the closure is held (fail closed).
pub const MAX_ROUNDS: usize = 64;
/// What a closure round is given.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClosureInput {
pub group: String,
pub artifact: String,
pub version: String,
/// Each POM fetched so far: its path below the repository → its file in the input.
pub poms: BTreeMap<String, String>,
/// The POM paths the repository does not publish.
pub absent_poms: BTreeSet<String>,
/// Each `maven-metadata.xml` fetched so far, by `<group path>/<artifact>` → its file.
pub metadata: BTreeMap<String, String>,
/// The metadata the repository does not publish, by `<group path>/<artifact>`.
pub absent_metadata: BTreeSet<String>,
}
/// One file of the closure, as the job answers it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClosedFile {
pub path: String,
/// [`super::pom::Role::name`].
pub role: String,
pub via: String,
}
/// A closure round's answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ClosureAnswer {
/// The walk reached these and was not given them: fetch them, run again.
Need {
poms: Vec<String>,
/// `<group path>/<artifact>`.
metadata: Vec<String>,
},
/// The closure.
Done {
files: Vec<ClosedFile>,
unresolved: Vec<(String, String)>,
},
}
/// What a `maven-metadata.xml` says.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetadataAnswer {
/// `<release>`, else the last non-SNAPSHOT `<version>`.
pub release: Option<String>,
/// A group's plugins: `(name, prefix, artifactId)`.
pub plugins: Vec<(String, String, String)>,
}
/// What a wheel's core metadata requires.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WheelAnswer {
/// Whether core metadata was found at all.
pub found: bool,
/// Each `Requires-Dist` whose marker holds: `(normalized name, specifier)`.
pub requires: Vec<(String, String)>,
}
// ── the programs (in the job) ───────────────────────────────────────────────
/// The most bytes of the closure's index a job reads.
const MAX_INDEX_BYTES: u64 = 16 * 1024 * 1024;
/// The most bytes of a wheel's core metadata a job reads (as the pull always read it).
const MAX_WHEEL_METADATA: usize = 16 * 1024 * 1024;
/// The most bytes of a pulled wheel a job opens.
const MAX_WHEEL_BYTES: u64 = 4 * 1024 * 1024 * 1024;
type Refused = (String, String);
fn refused(why: impl std::fmt::Display) -> Refused {
(
holger_errcode::SANDBOX_CONFIG.code.into(),
holger_errcode::SANDBOX_CONFIG.tag(why.to_string()),
)
}
/// A file the server put into the job: a plain name, read under `cap`.
fn read_input(dirs: &Dirs, name: &str, cap: u64) -> Result<Vec<u8>, Refused> {
if name.is_empty()
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|| name.starts_with('.')
{
return Err(refused(format!("the job's input names the file {name:?}")));
}
let p = dirs.input.join(name);
let len = std::fs::metadata(&p)
.map_err(|e| refused(format!("the job's input {name}: {e}")))?
.len();
if len > cap {
return Err(refused(format!(
"the job's input {name} is {len} bytes, past {cap}"
)));
}
std::fs::read(&p).map_err(|e| refused(format!("the job's input {name}: {e}")))
}
fn answer<T: Serialize>(dirs: &Dirs, t: &T) -> Answer {
match serde_json::to_vec(t)
.map_err(|e| e.to_string())
.and_then(|b| std::fs::write(dirs.out.join(RESULT_FILE), b).map_err(|e| e.to_string()))
{
Ok(()) => Answer::accepted(vec![RESULT_FILE.into()]),
Err(e) => Answer::refused(
holger_errcode::SANDBOX_JOB_ANSWER_INVALID.code,
holger_errcode::SANDBOX_JOB_ANSWER_INVALID.tag(format!("{RESULT_FILE}: {e}")),
),
}
}
fn finish<T: Serialize>(dirs: &Dirs, r: Result<T, Refused>) -> Answer {
match r {
Ok(t) => answer(dirs, &t),
Err((code, reason)) => Answer::refused(code, reason),
}
}
/// The POMs and metadata a closure round was given; what it reached and was not given is
/// noted, and the walk goes on as if the repository did not publish it.
struct Given<'a> {
dirs: &'a Dirs,
idx: &'a ClosureInput,
need_poms: std::cell::RefCell<BTreeSet<String>>,
need_metadata: std::cell::RefCell<BTreeSet<String>>,
}
impl super::pom::PomSource for Given<'_> {
fn pom(&self, path: &str) -> anyhow::Result<Option<Vec<u8>>> {
if let Some(f) = self.idx.poms.get(path) {
return read_input(self.dirs, f, super::pom::MAX_POM_BYTES)
.map(Some)
.map_err(|(_, why)| anyhow::anyhow!(why));
}
if !self.idx.absent_poms.contains(path) {
self.need_poms.borrow_mut().insert(path.to_string());
}
Ok(None)
}
fn versions(&self, group_path: &str, artifact: &str) -> anyhow::Result<Option<Vec<String>>> {
let key = format!("{group_path}/{artifact}");
if let Some(f) = self.idx.metadata.get(&key) {
let b = read_input(self.dirs, f, super::pom::MAX_POM_BYTES)
.map_err(|(_, why)| anyhow::anyhow!(why))?;
return Ok(Some(super::pom::metadata_versions(
&String::from_utf8_lossy(&b),
)));
}
if !self.idx.absent_metadata.contains(&key) {
self.need_metadata.borrow_mut().insert(key);
}
Ok(None)
}
}
/// **[`MAVEN_CLOSURE`]**: one round of a version's closure over what the server fetched.
pub fn run_closure(_job: &WireJob, dirs: &Dirs) -> Answer {
finish(dirs, closure_round(dirs))
}
fn closure_round(dirs: &Dirs) -> Result<ClosureAnswer, Refused> {
let idx: ClosureInput = serde_json::from_slice(&read_input(dirs, INDEX_FILE, MAX_INDEX_BYTES)?)
.map_err(|e| refused(format!("{INDEX_FILE}: {e}")))?;
let given = Given {
dirs,
idx: &idx,
need_poms: Default::default(),
need_metadata: Default::default(),
};
let root = super::pom::Gav::new(&idx.group, &idx.artifact, &idx.version);
let c =
super::pom::closure(&given, &root).map_err(|e| refused(format!("the closure: {e:#}")))?;
let (poms, metadata) = (
given.need_poms.into_inner(),
given.need_metadata.into_inner(),
);
Ok(if poms.is_empty() && metadata.is_empty() {
ClosureAnswer::Done {
files: c
.files
.into_iter()
.map(|f| ClosedFile {
path: f.path,
role: f.role.name().into(),
via: f.via,
})
.collect(),
unresolved: c.unresolved,
}
} else {
ClosureAnswer::Need {
poms: poms.into_iter().collect(),
metadata: metadata.into_iter().collect(),
}
})
}
/// **[`MAVEN_METADATA`]**: what a `maven-metadata.xml` says.
pub fn run_metadata(_job: &WireJob, dirs: &Dirs) -> Answer {
finish(
dirs,
read_input(dirs, METADATA_XML, super::pom::MAX_POM_BYTES).map(|b| {
let t = String::from_utf8_lossy(&b);
MetadataAnswer {
release: super::maven::newest_release_of(&t),
plugins: super::maven::group_plugins(&t),
}
}),
)
}
/// **[`WHEEL_REQUIRES`]**: the `Requires-Dist` of a wheel's core metadata that hold.
pub fn run_wheel(_job: &WireJob, dirs: &Dirs) -> Answer {
finish(dirs, wheel_requires(dirs))
}
fn wheel_requires(dirs: &Dirs) -> Result<WheelAnswer, Refused> {
let meta = if dirs.input.join(METADATA_FILE).exists() {
Some(read_input(dirs, METADATA_FILE, MAX_WHEEL_METADATA as u64)?)
} else {
let wheel = read_input(dirs, WHEEL_FILE, MAX_WHEEL_BYTES)?;
zip_entry::find_member_capped(&wheel, MAX_WHEEL_METADATA, |n| {
let mut parts = n.split('/');
matches!((parts.next(), parts.next(), parts.next()), (Some(d), Some("METADATA"), None) if d.ends_with(".dist-info"))
})
.ok()
.flatten()
.map(|(_, m)| m)
};
Ok(match meta {
None => WheelAnswer::default(),
Some(m) => WheelAnswer {
found: true,
requires: super::closures::requires_dist(&String::from_utf8_lossy(&m))
.iter()
.filter_map(|r| super::closures::requirement(r))
.collect(),
},
})
}
// ── the server's half ───────────────────────────────────────────────────────
/// **Run `program` over `inputs` in a job of the chain's runner** and believe only its signed
/// answer ([`crate::job_runner::Jobs::run`]). No runner, or a refusal, holds the pull by name
/// ([`crate::jobs::JobPermanent`]); a runner that did not run it is retried.
pub(super) fn run_job<T: serde::de::DeserializeOwned>(
chain: &crate::promote::intake::IntakeChain,
base: &std::path::Path,
program: &str,
inputs: &[(String, &[u8])],
) -> anyhow::Result<T> {
use crate::job_runner::heimdall_core::{Job, JobId, Limits, Nonce, Policy, Verdict};
use crate::job_runner::{holger_job_runner as runner, JobDirs, JOBS_DIR};
let Some(jobs) = chain.jobs().cloned() else {
anyhow::bail!(crate::jobs::JobPermanent(format!(
"this server has no job runner, and a {program} manifest is read only in a job (SEC-D3)"
)));
};
let id = JobId::random();
let dirs = JobDirs::new(&base.join(JOBS_DIR), &id)?;
for (name, bytes) in inputs {
std::fs::write(dirs.input.join(name), bytes)?;
}
let job = Job {
id,
nonce: Nonce::random(),
kind: runner::worker(&[program]),
input_dir: dirs.input.clone(),
output_dir: dirs.output.clone(),
params: serde_json::json!({}),
policy: Policy {
limits: Limits {
wall: std::time::Duration::from_secs(120),
memory_bytes: 1 << 30,
cpus: 1.0,
pids: 16,
..Default::default()
},
..Default::default()
},
};
let r = jobs
.run(job)
.map_err(|f| anyhow::anyhow!("the {program} job: {f}"))?;
if let Verdict::Refused { code, reason } = &r.verdict {
anyhow::bail!(crate::jobs::JobPermanent(format!(
"the {program} job refused it: [{code}] {reason}"
)));
}
let bytes = std::fs::read(dirs.output.join(RESULT_FILE))?;
serde_json::from_slice(&bytes).map_err(|e| {
anyhow::anyhow!(crate::jobs::JobPermanent(format!(
"the {program} job's {RESULT_FILE}: {e}"
)))
})
}
/// **A version's closure, resolved in jobs** ([`MAVEN_CLOSURE`], round by round): the server
/// fetches only the bytes each round names (`pom`, `metadata` by `<group path>/<artifact>`) and
/// parses none of them. Past [`MAX_ROUNDS`] the closure is held.
pub(super) fn closure_in_jobs(
chain: &crate::promote::intake::IntakeChain,
base: &std::path::Path,
root: &super::pom::Gav,
pom: &dyn Fn(&str) -> anyhow::Result<Option<Vec<u8>>>,
metadata: &dyn Fn(&str) -> anyhow::Result<Option<Vec<u8>>>,
) -> anyhow::Result<super::pom::Closure> {
let mut idx = ClosureInput {
group: root.group.clone(),
artifact: root.artifact.clone(),
version: root.version.clone(),
..ClosureInput::default()
};
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for _ in 0..MAX_ROUNDS {
let index = serde_json::to_vec(&idx)?;
let mut inputs: Vec<(String, &[u8])> = files
.iter()
.map(|(n, b)| (n.clone(), b.as_slice()))
.collect();
inputs.push((INDEX_FILE.into(), index.as_slice()));
match run_job::<ClosureAnswer>(chain, base, MAVEN_CLOSURE, &inputs)? {
ClosureAnswer::Done { files, unresolved } => {
return Ok(super::pom::Closure {
files: files
.into_iter()
.filter_map(|f| {
Some(super::pom::ClosureFile {
role: super::pom::Role::from_name(&f.role)?,
path: f.path,
via: f.via,
})
})
.collect(),
unresolved,
})
}
ClosureAnswer::Need {
poms,
metadata: wanted,
} => {
if poms.is_empty() && wanted.is_empty() {
anyhow::bail!(crate::jobs::JobPermanent(
"the maven-closure job needs nothing and answers no closure".into()
));
}
for p in poms {
if idx.poms.contains_key(&p) || idx.absent_poms.contains(&p) {
anyhow::bail!(crate::jobs::JobPermanent(format!(
"the maven-closure job asks again for {p}"
)));
}
match pom(&p)? {
Some(b) => {
let f = format!("p-{:06}", files.len());
files.push((f.clone(), b));
idx.poms.insert(p, f);
}
None => {
idx.absent_poms.insert(p);
}
}
}
for k in wanted {
if idx.metadata.contains_key(&k) || idx.absent_metadata.contains(&k) {
anyhow::bail!(crate::jobs::JobPermanent(format!(
"the maven-closure job asks again for {k}/maven-metadata.xml"
)));
}
match metadata(&k)? {
Some(b) => {
let f = format!("x-{:06}", files.len());
files.push((f.clone(), b));
idx.metadata.insert(k, f);
}
None => {
idx.absent_metadata.insert(k);
}
}
}
}
}
}
Ok(super::pom::Closure {
files: Vec::new(),
unresolved: vec![(
root.to_string(),
format!("the closure took more than {MAX_ROUNDS} rounds; held"),
)],
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::job_runner::heimdall_core::Verdict;
use holger_job_runner::wire::{Answer, Dirs, WireJob, JOB_SCHEMA};
/// Run `program` in-process over `inputs`, as the worker would: the answer and the output.
fn run(program: &str, inputs: &[(String, Vec<u8>)]) -> (Answer, Option<Vec<u8>>) {
let dir = tempfile::tempdir().unwrap();
let (input, out) = (dir.path().join("in"), dir.path().join("out"));
std::fs::create_dir_all(&input).unwrap();
std::fs::create_dir_all(&out).unwrap();
for (name, bytes) in inputs {
std::fs::write(input.join(name), bytes).unwrap();
}
let job = WireJob {
schema: JOB_SCHEMA.into(),
job_id: "manifest-1".into(),
argv: vec![program.into()],
params: serde_json::json!({}),
reads: 0,
answer: None,
};
let dirs = Dirs {
input,
read: vec![],
out: out.clone(),
};
let a = crate::job_runner::programs::run(&job, &dirs);
let r = std::fs::read(out.join(RESULT_FILE)).ok();
(a, r)
}
fn pom(g: &str, a: &str, v: &str, body: &str) -> String {
format!(
"<project><modelVersion>4.0.0</modelVersion><groupId>{g}</groupId>\
<artifactId>{a}</artifactId><version>{v}</version>{body}</project>"
)
}
/// The fake repository: `com.example:app:1.0` (parent `com.example:parent:1`, which names
/// `lib.version`), depending on `com.example:lib:${lib.version}` and on
/// `com.example:ranged:[1,2)` (a range: the metadata is read).
fn repo() -> BTreeMap<String, Vec<u8>> {
let mut m = BTreeMap::new();
m.insert(
"com/example/parent/1/parent-1.pom".into(),
pom(
"com.example",
"parent",
"1",
"<packaging>pom</packaging><properties><lib.version>2.0</lib.version></properties>",
)
.into_bytes(),
);
m.insert(
"com/example/app/1.0/app-1.0.pom".into(),
"<project><modelVersion>4.0.0</modelVersion>\
<parent><groupId>com.example</groupId><artifactId>parent</artifactId><version>1</version></parent>\
<artifactId>app</artifactId><version>1.0</version><dependencies>\
<dependency><groupId>com.example</groupId><artifactId>lib</artifactId><version>${lib.version}</version></dependency>\
<dependency><groupId>com.example</groupId><artifactId>ranged</artifactId><version>[1,2)</version></dependency>\
</dependencies></project>"
.to_string()
.into_bytes(),
);
m.insert(
"com/example/lib/2.0/lib-2.0.pom".into(),
pom("com.example", "lib", "2.0", "").into_bytes(),
);
m.insert(
"com/example/ranged/1.5/ranged-1.5.pom".into(),
pom("com.example", "ranged", "1.5", "").into_bytes(),
);
m.insert(
"com/example/ranged/maven-metadata.xml".into(),
b"<metadata><versioning><versions><version>1.0</version><version>1.5</version>\
<version>2.0</version></versions></versioning></metadata>"
.to_vec(),
);
m
}
/// The server's half of the rounds over `repo`: give the job what it named, until it answers.
fn resolve(repo: &BTreeMap<String, Vec<u8>>) -> (ClosureAnswer, usize) {
let mut idx = ClosureInput {
group: "com.example".into(),
artifact: "app".into(),
version: "1.0".into(),
..ClosureInput::default()
};
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for round in 1..=MAX_ROUNDS {
let mut inputs = files.clone();
inputs.push((INDEX_FILE.into(), serde_json::to_vec(&idx).unwrap()));
let (a, out) = run(MAVEN_CLOSURE, &inputs);
assert_eq!(a.verdict, Verdict::Accepted, "round {round}: {a:?}");
assert_eq!(a.outputs, vec![RESULT_FILE.to_string()]);
let ans: ClosureAnswer = serde_json::from_slice(&out.unwrap()).unwrap();
match ans {
ClosureAnswer::Need { poms, metadata } => {
assert!(
!poms.is_empty() || !metadata.is_empty(),
"a need names something"
);
for p in poms {
match repo.get(&p) {
Some(b) => {
let f = format!("p-{:06}", files.len());
files.push((f.clone(), b.clone()));
idx.poms.insert(p, f);
}
None => {
idx.absent_poms.insert(p);
}
}
}
for k in metadata {
match repo.get(&format!("{k}/maven-metadata.xml")) {
Some(b) => {
let f = format!("x-{:06}", files.len());
files.push((f.clone(), b.clone()));
idx.metadata.insert(k, f);
}
None => {
idx.absent_metadata.insert(k);
}
}
}
}
done => return (done, round),
}
}
panic!("no closure in {MAX_ROUNDS} rounds");
}
/// ★ **A Maven closure is resolved in jobs, round by round**: the job names what it needs,
/// the server fetches only bytes, and the last round answers the closure (the parent's
/// property, the range read from the metadata in the job).
#[test]
fn a_maven_closure_is_resolved_in_jobs_round_by_round() {
let (ans, rounds) = resolve(&repo());
let ClosureAnswer::Done { files, unresolved } = ans else {
unreachable!()
};
assert!(unresolved.is_empty(), "{unresolved:?}");
let paths: BTreeSet<&str> = files.iter().map(|f| f.path.as_str()).collect();
for want in [
"com/example/app/1.0/app-1.0.pom",
"com/example/parent/1/parent-1.pom",
"com/example/lib/2.0/lib-2.0.pom",
"com/example/lib/2.0/lib-2.0.jar",
"com/example/ranged/1.5/ranged-1.5.pom",
"com/example/ranged/1.5/ranged-1.5.jar",
] {
assert!(paths.contains(want), "{want} is in the closure: {paths:?}");
}
assert!(
files
.iter()
.any(|f| f.path.ends_with("parent-1.pom") && f.role == "parent"),
"{files:?}"
);
assert!(rounds <= 6, "{rounds} rounds");
}
/// ★ **A property bomb is held in the job** (D1): the server never expands it, and the job's
/// answer names the version unresolved.
#[test]
fn a_property_bomb_is_held_in_the_job() {
let mut repo = repo();
let a = "${a}".repeat(100);
repo.insert(
"com/example/app/1.0/app-1.0.pom".into(),
pom(
"com.example",
"app",
"1.0",
&format!(
"<properties><a>{a}</a></properties><dependencies><dependency>\
<groupId>com.example</groupId><artifactId>lib</artifactId>\
<version>${{a}}</version></dependency></dependencies>"
),
)
.into_bytes(),
);
let (ans, _) = resolve(&repo);
let ClosureAnswer::Done { unresolved, .. } = ans else {
unreachable!()
};
assert!(!unresolved.is_empty(), "the bomb holds the set");
}
/// ★ **A `maven-metadata.xml` is read in a job**: the newest release and a group's plugins.
#[test]
fn maven_metadata_is_read_in_a_job() {
let xml = b"<metadata><versioning><release>3.1</release><versions><version>3.0</version>\
<version>3.1</version></versions></versioning><plugins><plugin><name>Dep</name>\
<prefix>dependency</prefix><artifactId>maven-dependency-plugin</artifactId>\
</plugin></plugins></metadata>";
let (a, out) = run(MAVEN_METADATA, &[(METADATA_XML.into(), xml.to_vec())]);
assert_eq!(a.verdict, Verdict::Accepted, "{a:?}");
let m: MetadataAnswer = serde_json::from_slice(&out.unwrap()).unwrap();
assert_eq!(m.release.as_deref(), Some("3.1"));
assert_eq!(
m.plugins,
vec![(
"Dep".to_string(),
"dependency".to_string(),
"maven-dependency-plugin".to_string()
)]
);
}
/// ★ **A wheel's core metadata is read in a job**: from PEP 658's document, or from the
/// wheel itself; only the requirements whose marker holds come back.
#[test]
fn wheel_requires_are_read_in_a_job() {
let meta = "Metadata-Version: 2.1\nName: app\nVersion: 1.0\nRequires-Dist: dep<2,>=1.0\n\
Requires-Dist: winonly; sys_platform == \"win32\"\n\
Requires-Dist: extra-dep; extra == \"x\"\n\nRequires-Dist: body\n";
let want = vec![("dep".to_string(), "<2,>=1.0".to_string())];
let (a, out) = run(WHEEL_REQUIRES, &[(METADATA_FILE.into(), meta.into())]);
assert_eq!(a.verdict, Verdict::Accepted, "{a:?}");
let w: WheelAnswer = serde_json::from_slice(&out.unwrap()).unwrap();
assert!(w.found);
assert_eq!(w.requires, want);
let wheel = zip_entry::fixture::zip(&[
("app-1.0.dist-info/METADATA", meta.as_bytes()),
("app/__init__.py", b"\n"),
]);
let (a, out) = run(WHEEL_REQUIRES, &[(WHEEL_FILE.into(), wheel)]);
assert_eq!(a.verdict, Verdict::Accepted, "{a:?}");
let w: WheelAnswer = serde_json::from_slice(&out.unwrap()).unwrap();
assert_eq!((w.found, w.requires), (true, want));
let none = zip_entry::fixture::zip(&[("app/__init__.py", b"\n")]);
let (_, out) = run(WHEEL_REQUIRES, &[(WHEEL_FILE.into(), none)]);
let w: WheelAnswer = serde_json::from_slice(&out.unwrap()).unwrap();
assert!(!w.found);
}
}