Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/fourda-depengine/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,13 @@ fn answer(request: Request) -> Result<Value, String> {
let walk = outcome
.target
.map(|w| json!({ "version": w.version, "clears_all": w.clears_all }));
json!({ "line_fixes": line_fixes, "walk": walk, "withheld": outcome.withheld })
let mut out =
json!({ "line_fixes": line_fixes, "walk": walk, "withheld": outcome.withheld });
// Only when set, so every earlier answer keeps its exact shape.
if !outcome.introduces.is_empty() {
out["introduces"] = json!(outcome.introduces);
}
out
}
Request::MatchInventory {
inventory,
Expand Down
50 changes: 42 additions & 8 deletions crates/fourda-depengine/src/fix_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ pub struct WalkOutcome {
/// then withdrawn, so the honest answer is "no installable fix", not "no
/// fix". Empty when the walk could not evaluate an advisory.
pub withheld: Vec<String>,
/// Parity decision 2 (a target never brings in an advisory): indices
/// into the walk's `ranges` of the advisories with no way out that affect
/// where the walk lands but NOT the installed version. Non-empty means
/// every fixing release brings them in, and `target` is `None`.
pub introduces: Vec<usize>,
}

/// A line fix as a release the registry still offers ([`installable_fix`]).
Expand Down Expand Up @@ -411,21 +416,38 @@ pub fn walk_target_in(
) -> WalkOutcome {
let mut withheld = Vec::new();
match walk(ecosystem, installed, ranges, registry, &mut withheld) {
Ok(target) => WalkOutcome { target, withheld },
Ok(Landing::At(target)) => WalkOutcome {
target,
withheld,
introduces: Vec::new(),
},
Ok(Landing::Introduces(introduces)) => WalkOutcome {
target: None,
withheld,
introduces,
},
Err(Unreadable) => WalkOutcome::default(),
}
}

/// The walk met an advisory it cannot evaluate on its path.
struct Unreadable;

/// Where the walk ends.
enum Landing {
At(Option<WalkTarget>),
/// It lands inside advisories with no way out that the installed version
/// does not have (these indices): every fixing release brings them in.
Introduces(Vec<usize>),
}

fn walk(
ecosystem: &str,
installed: &str,
ranges: &[&Option<String>],
registry: &RegistryReleases,
withheld: &mut Vec<String>,
) -> Result<Option<WalkTarget>, Unreadable> {
) -> Result<Landing, Unreadable> {
let parsed: Vec<Option<Vec<Range>>> = ranges
.iter()
.map(|r| r.as_deref().and_then(|j| serde_json::from_str(j).ok()))
Expand All @@ -436,16 +458,16 @@ fn walk(
for _ in 0..MAX_HOPS {
let here = OrderedVersion::parse_in(&current, ecosystem).ok_or(Unreadable)?;
let mut next: Option<Release> = None;
let mut stuck = false;
for (json, advisory) in ranges.iter().zip(&parsed) {
let mut stuck: Vec<usize> = Vec::new();
for (index, (json, advisory)) in ranges.iter().zip(&parsed).enumerate() {
match check_version_affected(ecosystem, Some(&current), json) {
(false, _) => continue,
(true, false) => return Err(Unreadable),
(true, true) => {}
}
let advisory = advisory.as_deref().ok_or(Unreadable)?;
match exit(ecosystem, &here, advisory, &known, withheld) {
Exit::Stuck => stuck = true,
Exit::Stuck => stuck.push(index),
Exit::To(step) => {
let replace = match next.as_ref() {
None => true,
Expand All @@ -459,11 +481,23 @@ fn walk(
}
match next {
Some((_, raw)) => current = raw,
None if current == start => return Ok(Landing::At(None)),
None => {
return Ok((current != start).then_some(WalkTarget {
// Never bring an advisory in (parity decision 2): one with no
// way out that the installed version does not have blocks the
// target; the MCP's `smallestCleanVersion(installed)` agrees.
let introduced: Vec<usize> = stuck
.iter()
.copied()
.filter(|&i| !check_version_affected(ecosystem, Some(start), ranges[i]).0)
.collect();
if !introduced.is_empty() {
return Ok(Landing::Introduces(introduced));
}
return Ok(Landing::At(Some(WalkTarget {
version: current,
clears_all: !stuck,
}))
clears_all: stuck.is_empty(),
})));
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions crates/fourda-depengine/src/fix_target_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,28 @@ fn installable_fix_skips_a_release_the_same_advisory_still_affects() {
LineFix::default()
);
}

/// Parity decision 2 (review of #994, B2): A is fixed at 1.2.0, B opens at
/// 1.1.0 with no fix. From 1.0.0 every fixing release brings B in, so no
/// target is named (the MCP's `smallestCleanVersion(installed)` agrees);
/// from 1.1.5, which already has B, 1.2.0 clears A and B remains.
#[test]
fn a_target_never_brings_in_an_advisory_the_installed_version_lacks() {
let ab = [
ranges(r#"[{"introduced":"0"},{"fixed":"1.2.0"}]"#),
ranges(r#"[{"introduced":"1.1.0"}]"#),
];
let out = walk_in("1.0.0", &ab, &RegistryReleases::default());
assert_eq!(out.target, None);
assert_eq!(out.introduces, [1]);

let has_b = walk_in("1.1.5", &ab, &RegistryReleases::default());
assert_eq!(
has_b.target,
Some(WalkTarget {
version: "1.2.0".into(),
clears_all: false
})
);
assert!(has_b.introduces.is_empty());
}
33 changes: 32 additions & 1 deletion crates/fourda-depengine/src/lines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ pub struct LineTarget {
pub clears_all_known: bool,
pub upgrade_type: Option<UpgradeType>,
pub sites: Vec<InstallSite>,
/// Parity decision 2: every fixing release brings in these advisories,
/// which the installed version does not have, so `target_version` is
/// `None` (no line fix is offered in its place either). Sorted, deduped.
pub introduces: Vec<String>,
}

impl LineTarget {
Expand All @@ -57,6 +61,7 @@ where
walked: Option<String>,
partial: Option<(Version, String)>,
sites: BTreeMap<String, InstallSite>,
introduces: Vec<String>,
}
let mut by_version: BTreeMap<String, Acc> = BTreeMap::new();
let instances = copies
Expand All @@ -74,13 +79,15 @@ where
walked: None,
partial: None,
sites: BTreeMap::new(),
introduces: Vec::new(),
});
if acc.clean.is_none() {
acc.clean.clone_from(&d.clean_version);
}
if acc.walked.is_none() {
acc.walked.clone_from(&d.target_version);
}
acc.introduces.extend(d.introduces.iter().cloned());
if let Some(fix) = d
.fixed_version
.as_deref()
Expand Down Expand Up @@ -110,7 +117,15 @@ where
// Clean; else where the walk landed (past every window that has
// a way out); else, when the walk could not run, the highest
// own-line fix.
let target_version = acc.clean.or(acc.walked).or(acc.partial.map(|(_, f)| f));
// A target never brings an advisory in (parity decision 2).
let mut introduces = acc.introduces;
introduces.sort();
introduces.dedup();
let target_version = if introduces.is_empty() {
acc.clean.or(acc.walked).or(acc.partial.map(|(_, f)| f))
} else {
None
};
let upgrade_type = target_version
.as_deref()
.and_then(|t| upgrade_type(&installed, t));
Expand All @@ -120,6 +135,7 @@ where
clears_all_known,
upgrade_type,
sites: acc.sites.into_values().collect(),
introduces,
}
})
.collect();
Expand Down Expand Up @@ -188,6 +204,7 @@ mod tests {
clean_version: clean.map(str::to_string),
target_version: walked.map(str::to_string),
withheld: Vec::new(),
introduces: Vec::new(),
}
}

Expand Down Expand Up @@ -270,4 +287,18 @@ mod tests {
assert!(lines[0].is_major());
assert_eq!(describe_lines(&lines), "1.0.0 -> 2.0.1 (major)");
}

/// Parity decision 2: a copy whose every fixing release brings in an
/// advisory gets no target, not even its line fix.
#[test]
fn a_line_whose_fixes_bring_in_an_advisory_has_no_target() {
let copies = [MatchedDependency {
introduces: vec!["B".to_string()],
..copy("/a", "1.0.0", Some("1.2.0"), None, None, true, false)
}];
let lines = line_targets(&copies, &["/a".to_string()]);
assert_eq!(lines[0].target_version, None);
assert_eq!(lines[0].introduces, ["B"]);
assert!(distinct_targets(&lines).is_empty());
}
}
35 changes: 34 additions & 1 deletion crates/fourda-depengine/src/matching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ pub struct MatchedDependency {
/// but was withdrawn ("no installable fix", not "no fix published").
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub withheld: Vec<String>,
/// Parity decision 2: the advisories every fixing release of this copy
/// brings in (no way out, and the installed version does not have them).
/// Sorted and deduped. Non-empty means no target is named: neither `target_version` nor the
/// line fix (`fourda_depengine::fix_target::WalkOutcome::introduces`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub introduces: Vec<String>,
}

/// One advisory row that affects at least one installed copy.
Expand Down Expand Up @@ -195,7 +201,15 @@ where
// Every known advisory's ranges per package: a copy's clean version must
// clear ALL of them, not only the ones it matched today.
let mut package_ranges: HashMap<(String, String), Vec<&Option<String>>> = HashMap::new();
let mut package_ids: HashMap<(String, String), Vec<&str>> = HashMap::new();
for record in records {
package_ids
.entry((
package_key(&record.package_name, &record.ecosystem),
normalize_ecosystem(&record.ecosystem).to_string(),
))
.or_default()
.push(&record.advisory_id);
package_ranges
.entry((
package_key(&record.package_name, &record.ecosystem),
Expand Down Expand Up @@ -250,7 +264,8 @@ where
.unwrap_or_default()
})
.clone();
CopyTargets::new(record, v, walk, facts)
let ids = package_ids.get(&key).map_or(&[][..], Vec::as_slice);
CopyTargets::new(record, v, walk, facts, ids)
}
_ => CopyTargets::default(),
};
Expand All @@ -264,6 +279,7 @@ where
clean_version: targets.clean,
target_version: targets.target,
withheld: targets.withheld,
introduces: targets.introduces,
});
}
}
Expand Down Expand Up @@ -348,14 +364,17 @@ struct CopyTargets {
clean: Option<String>,
target: Option<String>,
withheld: Vec<String>,
introduces: Vec<String>,
}

impl CopyTargets {
/// `ids`: the package's advisory ids, parallel to the walk's ranges.
fn new(
record: &AdvisoryRecord,
installed: &str,
walk: WalkOutcome,
facts: &RegistryReleases,
ids: &[&str],
) -> Self {
let line =
installable_line_fix(&record.ecosystem, installed, &record.affected_ranges, facts);
Expand All @@ -374,10 +393,24 @@ impl CopyTargets {
.map(|w| w.version.clone()),
target: walk.target.map(|w| w.version),
withheld,
introduces: introduced_ids(&walk.introduces, ids),
}
}
}

/// The ids at `indices` of `ids`, sorted and deduped (the order both
/// engines name them in).
fn introduced_ids(indices: &[usize], ids: &[&str]) -> Vec<String> {
let mut out: Vec<String> = indices
.iter()
.filter_map(|&i| ids.get(i))
.map(|id| (*id).to_string())
.collect();
out.sort();
out.dedup();
out
}

/// The versions checked for one resolved row: every installed copy of that
/// (project, package), deduped by version (first wins, with its own
/// direct/dev labels), then the resolved row's own version unless a copy
Expand Down
6 changes: 6 additions & 0 deletions scripts/conformance-corpus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -566,8 +566,14 @@ async function stepPin(cases, adjud) {
const ids = [...new Set(idLists.flat())].sort();
console.log(`pin: ${list.length} package names -> ${ids.length} advisories`);
const osvDir = join(CORPUS, "osv");
// SYNTHETIC records (SYNT-*.json) pin rule vectors no live case shows
// (fix_target ft-069); a re-pin keeps them, it never fetches them.
const synthetic = existsSync(osvDir)
? readdirSync(osvDir).filter((f) => f.startsWith("SYNT-")).map((f) => [f, readFileSync(join(osvDir, f))])
: [];
rmSync(osvDir, { recursive: true, force: true });
mkdirSync(osvDir, { recursive: true });
for (const [f, body] of synthetic) writeFileSync(join(osvDir, f), body);
await pool(ids, 16, async (id) => {
const rec = await fetchJson(`https://api.osv.dev/v1/vulns/${encodeURIComponent(id)}`, null, join(CACHE, "vulns", `${id}.json`));
if (rec) writeFileSync(join(osvDir, `${id}.json`), `${JSON.stringify(rec)}\n`);
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/brief_facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ pub(crate) enum FixPath {
/// No advisory on this copy has a way out on its line: no `fixed` bound,
/// and no release past a `last_affected` one (`osv::fix_target`).
NoFix,
/// Every fixing release brings in these advisories, which the installed
/// copy does not have (parity decision 2): no release is named.
Introduces { advisories: Vec<String> },
/// A fix exists, scope unknown.
Update { to: String },
}
Expand Down Expand Up @@ -716,7 +719,7 @@ pub(crate) fn package_facts(facts: &BriefFacts) -> Vec<crate::briefing_groundedn
add(&f.package, Some(&l.to));
}
}
FixPath::NoFix => {}
FixPath::NoFix | FixPath::Introduces { .. } => {}
}
}
}
Expand Down
Loading
Loading