Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
test(vex): extensive unit + conformance tests, OpenVEX 0.2.0 schema c…
…ompleteness

Drives line coverage of `socket-patch-core::vex` to ~99.7% (1846/1851
lines covered) and locks down OpenVEX 0.2.0 spec conformance.

Schema additions (all `Option<T>`, all `skip_serializing_if = "Option::is_none"`,
no change to what the builder emits today):
- `Document.role`, `Document.last_updated`
- `Statement.@id`, `Statement.last_updated`, `Statement.supplier`,
  `Statement.action_statement`
- `Product.identifiers`, `Product.hashes`
- `Subcomponent.identifiers`, `Subcomponent.hashes`
Backwards-compatible: existing docs round-trip unchanged; new fields
appear only when callers set them.

Test count went from 33 to 144 across the vex module (+111 tests):
- schema.rs:  6 → 24 — every Status/Justification variant, all new
              optional fields, missing-required-field rejection,
              version typing, multi-aliases ordering.
- build.rs:   7 → 17 — applied PURL not in manifest, zero-vuln patch,
              empty CVE list, duplicate CVE dedup, tooling=None,
              empty author, determinism, timestamp consistency,
              subcomponent sort order.
- product.rs: 22 → 53 — `[tool.poetry]` fallback, CRLF git config,
              all URL scheme branches (git+ssh, git://, http://,
              port-suffix, no-user), no-origin/empty-url config
              fallbacks, multi-manifest combos beyond pkg+cargo,
              non-string JSON name/version, missing-version-key,
              parse_toml_kv negative cases, three-segment URL path,
              trailing-slash normalization.
- verify.rs:  5 → 11 — empty manifest, zero-file patch (vacuous),
              extra package_paths ignored, multi-file short-circuit,
              Default/Clone/Eq impls.
- time.rs:    5 → 15 — non-leap Feb, year-end boundary, century
              non-leap (2100), 400-year leap (2000), every
              month-length transition, u64::MAX no-panic.
- mod.rs:     0 → 1 — re-export smoke test (compile-time guard).
- conformance_tests.rs (new): 17 cross-cutting tests pinning OpenVEX
              spec rules — @context literal, JSON-LD @-prefixed keys,
              status/justification interaction (action_statement
              reserved for status=affected; not_affected requires
              justification), required-field presence, non-empty
              identifiers, timestamp consistency, version=1,
              no-null invariant, alias/subcomponent uniqueness.

Remaining 5 uncovered regions documented in-source as unreachable in
practice (e.g. `civil_from_days` negative-`z` arm — requires inputs
past year ~292 billion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • Loading branch information
mikolalysenko and claude committed May 25, 2026
commit e978c84a892b2d1a9239c25e889c183c130a1a39
322 changes: 321 additions & 1 deletion crates/socket-patch-core/src/vex/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,39 +90,56 @@ pub fn build_document(
subcomponent_ids.sort();
let subcomponents = subcomponent_ids
.into_iter()
.map(|id| Subcomponent { id })
.map(|id| Subcomponent {
id,
identifiers: None,
hashes: None,
})
.collect();

let mut parts = group.impact_parts;
parts.sort();
parts.dedup();
// The `parts.is_empty()` branch is unreachable from the
// public API: the loop above pushes one entry per applied
// (purl, vuln) pair, so every group present in `grouped`
// has ≥1 entry. The defensive `None` arm stays in case a
// future refactor decouples grouping from impact tracking.
let impact_statement = if parts.is_empty() {
None
} else {
Some(parts.join("; "))
};

statements.push(Statement {
id: None,
vulnerability: Vulnerability {
name: vuln_id,
aliases,
},
timestamp: timestamp.clone(),
last_updated: None,
products: vec![Product {
id: opts.product_id.clone(),
identifiers: None,
hashes: None,
subcomponents,
}],
status: Status::NotAffected,
supplier: None,
justification: Some(Justification::InlineMitigationsAlreadyExist),
impact_statement,
action_statement: None,
});
}

Some(Document {
context: OPENVEX_CONTEXT_V0_2_0.to_string(),
id: opts.doc_id.clone(),
author: opts.author.clone(),
role: None,
timestamp,
last_updated: None,
version: 1,
tooling: opts.tooling.clone(),
statements,
Expand Down Expand Up @@ -323,4 +340,307 @@ mod tests {
assert_eq!(doc.tooling.as_deref(), Some("socket-patch 3.0.0"));
assert_eq!(doc.version, 1);
}

// ── Edge-case coverage ────────────────────────────────────────

/// `applied` references a PURL the manifest doesn't have. Must
/// not panic, must not emit a statement for the missing PURL.
#[test]
fn applied_purl_absent_from_manifest_is_silently_skipped() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/in-manifest@1.0.0".to_string(),
record("u1", vec![("GHSA-aaaa", vec!["CVE-1"])]),
);

let doc = build_document(
&manifest,
&[
"pkg:npm/in-manifest@1.0.0".to_string(),
"pkg:npm/ghost@9.9.9".to_string(), // not in manifest
],
&opts(),
)
.unwrap();

assert_eq!(doc.statements.len(), 1);
let subs = &doc.statements[0].products[0].subcomponents;
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].id, "pkg:npm/in-manifest@1.0.0");
}

/// A patch in the manifest with zero vulnerabilities contributes
/// no statements. Important: a patch is applied to fix files
/// *without* a vuln record (rare but legal) → silently skip.
#[test]
fn applied_patch_with_zero_vulnerabilities_emits_no_statement() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/with-vuln@1.0.0".to_string(),
record("u1", vec![("GHSA-aaaa", vec!["CVE-1"])]),
);
manifest.patches.insert(
"pkg:npm/no-vuln@2.0.0".to_string(),
record("u2", vec![]),
);

let doc = build_document(
&manifest,
&[
"pkg:npm/with-vuln@1.0.0".to_string(),
"pkg:npm/no-vuln@2.0.0".to_string(),
],
&opts(),
)
.unwrap();

assert_eq!(doc.statements.len(), 1);
let subs = &doc.statements[0].products[0].subcomponents;
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].id, "pkg:npm/with-vuln@1.0.0");
}

/// A vulnerability with an empty CVE list → statement carries
/// no `aliases` key (omit-when-empty per the serde attribute).
#[test]
fn empty_cve_list_produces_statement_with_no_aliases_key() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record("u1", vec![("GHSA-no-cves", vec![])]),
);
let doc = build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts())
.unwrap();
assert_eq!(doc.statements[0].vulnerability.aliases.len(), 0);

// Serialize and verify the JSON omits the `aliases` key.
let v = serde_json::to_value(&doc.statements[0]).unwrap();
assert!(v["vulnerability"]
.as_object()
.unwrap()
.get("aliases")
.is_none());
}

/// Two patches share a GHSA AND share a CVE → the CVE appears
/// once in `aliases` (dedup-by-HashSet semantics).
#[test]
fn duplicate_cve_across_patches_deduped_in_aliases() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record(
"u1",
vec![("GHSA-shared", vec!["CVE-SHARED", "CVE-X-ONLY"])],
),
);
manifest.patches.insert(
"pkg:npm/y@2.0.0".to_string(),
record(
"u2",
vec![("GHSA-shared", vec!["CVE-SHARED", "CVE-Y-ONLY"])],
),
);

let doc = build_document(
&manifest,
&[
"pkg:npm/x@1.0.0".to_string(),
"pkg:npm/y@2.0.0".to_string(),
],
&opts(),
)
.unwrap();

assert_eq!(doc.statements.len(), 1);
let aliases = &doc.statements[0].vulnerability.aliases;
// Three unique CVEs, sorted.
assert_eq!(
aliases.as_slice(),
&[
"CVE-SHARED".to_string(),
"CVE-X-ONLY".to_string(),
"CVE-Y-ONLY".to_string(),
]
);
}

/// Same patch UUID used by two PURLs that share a GHSA → the
/// impact_statement dedups the UUID-mention (no double-count).
#[test]
fn same_uuid_across_two_purls_deduped_in_impact_statement() {
// Two manifest entries, identical UUID and GHSA. Real world:
// the same patch package is fingerprinted against multiple
// installed versions. Builder must dedup the impact line.
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]),
);
manifest.patches.insert(
"pkg:npm/x@1.0.1".to_string(),
record("shared-uuid", vec![("GHSA-shared", vec!["CVE-1"])]),
);

let doc = build_document(
&manifest,
&[
"pkg:npm/x@1.0.0".to_string(),
"pkg:npm/x@1.0.1".to_string(),
],
&opts(),
)
.unwrap();
let imp = doc.statements[0].impact_statement.as_ref().unwrap();
// Count occurrences of "shared-uuid" — must be exactly 1.
assert_eq!(
imp.matches("shared-uuid").count(),
1,
"duplicate UUID must collapse: {imp}"
);
}

/// `BuildOptions.tooling = None` → `Document.tooling` is None and
/// the JSON output omits the key. Previously only `Some` was
/// asserted.
#[test]
fn tooling_none_omits_key_in_document() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record("u1", vec![("GHSA-x", vec![])]),
);
let opts = BuildOptions {
product_id: "pkg:npm/app@1.0.0".to_string(),
doc_id: "urn:uuid:t".to_string(),
author: "Socket".to_string(),
tooling: None,
};
let doc =
build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts)
.unwrap();
assert!(doc.tooling.is_none());

let v = serde_json::to_value(&doc).unwrap();
assert!(v.as_object().unwrap().get("tooling").is_none());
}

/// Empty author string is allowed through unchanged. We don't
/// special-case it; the CLI layer ensures a sensible default.
#[test]
fn empty_author_is_preserved_not_substituted() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record("u1", vec![("GHSA-x", vec![])]),
);
let opts = BuildOptions {
product_id: "pkg:npm/app@1.0.0".to_string(),
doc_id: "urn:uuid:t".to_string(),
author: String::new(),
tooling: None,
};
let doc =
build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts)
.unwrap();
assert_eq!(doc.author, "");
}

/// Two builds with the same inputs produce statements with
/// identical content and ordering. Timestamps may differ (the
/// builder calls `now_rfc3339`) but the `statements` field is
/// fully determined by the inputs.
#[test]
fn build_is_deterministic_modulo_timestamps() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record(
"u1",
vec![
("GHSA-bbbb", vec!["CVE-2", "CVE-1"]),
("GHSA-aaaa", vec!["CVE-3"]),
],
),
);
manifest.patches.insert(
"pkg:npm/y@2.0.0".to_string(),
record("u2", vec![("GHSA-aaaa", vec!["CVE-3"])]),
);

let applied = vec![
"pkg:npm/x@1.0.0".to_string(),
"pkg:npm/y@2.0.0".to_string(),
];

let a = build_document(&manifest, &applied, &opts()).unwrap();
let b = build_document(&manifest, &applied, &opts()).unwrap();

// Sanity-strip the per-run timestamp before comparing.
let strip = |mut d: Document| -> Document {
d.timestamp = String::new();
for s in d.statements.iter_mut() {
s.timestamp = String::new();
}
d
};
assert_eq!(strip(a), strip(b));
}

/// Every statement's `timestamp` equals the document's `timestamp`.
/// Builder pulls `now_rfc3339()` once and clones into each
/// statement; the contract is "one wall-clock per invocation".
#[test]
fn all_statement_timestamps_equal_document_timestamp() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/x@1.0.0".to_string(),
record(
"u1",
vec![("GHSA-a", vec!["CVE-1"]), ("GHSA-b", vec!["CVE-2"])],
),
);
let doc =
build_document(&manifest, &["pkg:npm/x@1.0.0".to_string()], &opts())
.unwrap();
for st in &doc.statements {
assert_eq!(st.timestamp, doc.timestamp);
}
}

/// Subcomponent IDs are sorted within a merged statement. Pin
/// this so downstream tools can rely on stable diff output.
#[test]
fn merged_subcomponents_are_sorted_alphabetically() {
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/zzz@1.0.0".to_string(),
record("u-z", vec![("GHSA-shared", vec![])]),
);
manifest.patches.insert(
"pkg:npm/aaa@1.0.0".to_string(),
record("u-a", vec![("GHSA-shared", vec![])]),
);
manifest.patches.insert(
"pkg:npm/mmm@1.0.0".to_string(),
record("u-m", vec![("GHSA-shared", vec![])]),
);

let doc = build_document(
&manifest,
&[
"pkg:npm/zzz@1.0.0".to_string(),
"pkg:npm/aaa@1.0.0".to_string(),
"pkg:npm/mmm@1.0.0".to_string(),
],
&opts(),
)
.unwrap();

let subs = &doc.statements[0].products[0].subcomponents;
assert_eq!(subs.len(), 3);
assert_eq!(subs[0].id, "pkg:npm/aaa@1.0.0");
assert_eq!(subs[1].id, "pkg:npm/mmm@1.0.0");
assert_eq!(subs[2].id, "pkg:npm/zzz@1.0.0");
}
}
Loading
Loading