Skip to main content

rto_exec/
crossref.rs

1//! Cross-referencing dependency findings across analyzers.
2//!
3//! `cargo-audit` and `osv-scanner` both read `Cargo.lock`, and OSV.dev ingests
4//! the `RustSec` database — so the same Rust advisory arrives twice, under
5//! `finding:cargo-audit:…` and `finding:osv-scanner:…`. [ADR-0018] v1.1 decides
6//! what to do about it: **keep both findings and cross-reference them at the
7//! reporting layer**. This module is that reporting layer's join.
8//!
9//! [ADR-0018]: https://github.com/OffeneDatenmodellierung/Roteiro/blob/main/docs/adr/0018-analyzer-coverage-matrix.md
10//!
11//! # Nothing here filters, merges, or renumbers
12//!
13//! A [`Correspondence`] is a *view over* findings, not a replacement for them.
14//! Every finding stays in its own layer, keyed as its own analyzer named it, and
15//! every layer is still replaced wholesale per analyzer. The count of findings is
16//! unchanged by anything in this file, which is the specific failure ADR-0018
17//! names: "never a merged super-finding, and never a count that silently halves".
18//! A duplicate pair reads as *one advisory confirmed by two analyzers*, with
19//! both [`Correspondence::keys`] still addressable — so a reader who fixes the
20//! advisory watches both disappear.
21//!
22//! # The join needs no invention
23//!
24//! Both upstreams publish the identifiers already. OSV keys a `RustSec`-derived
25//! record by *the RUSTSEC id itself* (`RUSTSEC-2020-0071` resolves, carrying
26//! `aliases: ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396"]`), and `cargo-audit`'s
27//! adapter stores the advisory's `aliases` and `related` verbatim in `meta`. So
28//! two findings correspond when their **identifier sets intersect** — the
29//! RUSTSEC id where both name it, any shared CVE or GHSA id otherwise. That is a
30//! deterministic join over published identifiers: no similarity matching, no
31//! heuristic, and nothing that needs a confidence score.
32//!
33//! # Why the package must match too
34//!
35//! Identifier intersection alone over-merges. A single CVE is regularly assigned
36//! to several packages, and joining on it alone would fuse advisories about
37//! different crates into one row. Correspondence therefore also requires the
38//! same package **at the same version**, which both adapters record in
39//! `meta.package` and `meta.version`. A finding without those — every SAST
40//! finding — is not on the dependency axis and does not take part at all.
41//!
42//! # "Present in one, absent in the other" is a real state
43//!
44//! The two analyzers pin their databases independently and are prefetched at
45//! different times, so they will legitimately disagree for a window, and there
46//! are advisory kinds only one of them can ever carry (`cargo-audit` learns
47//! *yanked* from the registry index, which is not an advisory and is not in OSV
48//! at all). A [`Correspondence`] reported by one analyzer is therefore a normal
49//! result, not a defect: [`Correspondence::confirmed_by`] answers *how many* said
50//! so, and the caller renders that rather than treating a single source as a
51//! discrepancy.
52//!
53//! @rto:0012
54//! @rto:0018
55
56use std::collections::BTreeMap;
57
58use rto_graph::{Finding, FindingsLayer, Severity};
59
60/// One advisory, and every finding that reported it.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Correspondence {
63    /// The advisory's canonical identifier: the RUSTSEC id where any member
64    /// names one — that is the id ADR-0018 calls the join key and the one a Rust
65    /// developer recognises — and otherwise the lowest identifier in the set.
66    pub advisory: String,
67    /// Every identifier this advisory is published under, across all members.
68    pub aliases: Vec<String>,
69    /// The package it is about.
70    pub package: String,
71    /// The version of that package that was resolved.
72    pub version: String,
73    /// One entry per reporting finding, ordered by analyzer then key.
74    pub reports: Vec<Report>,
75}
76
77/// One analyzer's report of an advisory.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Report {
80    /// The analyzer that reported it.
81    pub analyzer: String,
82    /// The finding's rendered [`rto_graph::FindingKey`] — still addressable, and
83    /// still owned by its own layer.
84    pub key: String,
85    /// The rule or advisory id *this* analyzer fired, which is not always the
86    /// canonical one: `osv-scanner` may name an advisory by its GHSA id where
87    /// `cargo-audit` names it by its RUSTSEC id.
88    pub rule: String,
89    /// The severity that analyzer assigned.
90    pub severity: Severity,
91}
92
93impl Correspondence {
94    /// How many distinct analyzers reported this advisory.
95    ///
96    /// Two or more is agreement between independent sources, which ADR-0018
97    /// keeps as evidence rather than tidying away. One is a normal state, not a
98    /// discrepancy — see the module docs.
99    #[must_use]
100    pub fn confirmed_by(&self) -> usize {
101        let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
102        analyzers.sort_unstable();
103        analyzers.dedup();
104        analyzers.len()
105    }
106
107    /// The distinct analyzers that reported it, sorted.
108    #[must_use]
109    pub fn analyzers(&self) -> Vec<&str> {
110        let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
111        analyzers.sort_unstable();
112        analyzers.dedup();
113        analyzers
114    }
115
116    /// Every finding key that reported it. Both halves of a duplicate pair stay
117    /// addressable; neither is superseded by this view.
118    #[must_use]
119    pub fn keys(&self) -> Vec<&str> {
120        self.reports.iter().map(|r| r.key.as_str()).collect()
121    }
122}
123
124/// Cross-reference the dependency findings across `layers`.
125///
126/// Returns one [`Correspondence`] per advisory-and-package, ordered by package,
127/// then version, then advisory — a stable order, so two runs over the same store
128/// render identically. Findings that are not on the dependency axis (no
129/// `meta.package`) are absent, because there is nothing about a SAST finding for
130/// a dependency scanner to agree with.
131///
132/// The findings themselves are neither modified nor consumed: this borrows them
133/// and describes what it saw.
134#[must_use]
135pub fn cross_reference(layers: &[FindingsLayer]) -> Vec<Correspondence> {
136    let candidates: Vec<Candidate<'_>> = layers
137        .iter()
138        .flat_map(|layer| {
139            layer
140                .findings
141                .iter()
142                .filter_map(|finding| Candidate::of(&layer.run.analyzer, finding))
143        })
144        .collect();
145
146    // Bucket by package and version first. Identifier intersection alone
147    // over-merges, because one CVE is regularly assigned to several packages.
148    let mut buckets: BTreeMap<(&str, &str), Vec<&Candidate<'_>>> = BTreeMap::new();
149    for candidate in &candidates {
150        buckets
151            .entry((candidate.package, candidate.version))
152            .or_default()
153            .push(candidate);
154    }
155
156    let mut out = Vec::new();
157    for ((package, version), members) in buckets {
158        for group in group_by_shared_identifier(&members) {
159            out.push(assemble(package, version, &group));
160        }
161    }
162    out.sort_by(|a, b| {
163        (&a.package, &a.version, &a.advisory).cmp(&(&b.package, &b.version, &b.advisory))
164    });
165    out
166}
167
168/// Partition one package's findings into groups whose identifier sets overlap,
169/// transitively.
170///
171/// Transitive closure is what makes the join work in the direction it has to:
172/// `cargo-audit` may name an advisory `RUSTSEC-x` with alias `CVE-y`, and
173/// `osv-scanner` may name it `GHSA-z` with alias `CVE-y`. Neither shares an id
174/// with the other directly; both share one with the CVE.
175fn group_by_shared_identifier<'a>(members: &[&'a Candidate<'a>]) -> Vec<Vec<&'a Candidate<'a>>> {
176    let mut parent: Vec<usize> = (0..members.len()).collect();
177    for (i, a) in members.iter().enumerate() {
178        for (j, b) in members.iter().enumerate().skip(i + 1) {
179            if a.shares_identifier(b) {
180                union(&mut parent, i, j);
181            }
182        }
183    }
184    let mut groups: BTreeMap<usize, Vec<&Candidate<'_>>> = BTreeMap::new();
185    for (i, member) in members.iter().enumerate() {
186        groups.entry(find(&mut parent, i)).or_default().push(member);
187    }
188    groups.into_values().collect()
189}
190
191fn find(parent: &mut [usize], mut node: usize) -> usize {
192    while parent[node] != node {
193        parent[node] = parent[parent[node]];
194        node = parent[node];
195    }
196    node
197}
198
199fn union(parent: &mut [usize], a: usize, b: usize) {
200    let (a, b) = (find(parent, a), find(parent, b));
201    if a != b {
202        parent[b.max(a)] = b.min(a);
203    }
204}
205
206/// Build the reported view of one group.
207fn assemble(package: &str, version: &str, group: &[&Candidate<'_>]) -> Correspondence {
208    let mut aliases: Vec<String> = group
209        .iter()
210        .flat_map(|c| c.identifiers.iter())
211        .map(String::clone)
212        .collect();
213    aliases.sort();
214    aliases.dedup();
215
216    let mut reports: Vec<Report> = group
217        .iter()
218        .map(|c| Report {
219            analyzer: c.analyzer.to_owned(),
220            key: c.key.clone(),
221            rule: c.rule.to_owned(),
222            severity: c.severity.clone(),
223        })
224        .collect();
225    reports.sort_by(|a, b| (&a.analyzer, &a.key).cmp(&(&b.analyzer, &b.key)));
226
227    Correspondence {
228        advisory: canonical(&reports),
229        aliases,
230        package: package.to_owned(),
231        version: version.to_owned(),
232        reports,
233    }
234}
235
236/// The identifier to name an advisory by: the RUSTSEC id an analyzer actually
237/// fired, otherwise the lowest id an analyzer fired.
238///
239/// **Only ids that were fired, never merely aliased**, and that restriction was
240/// put here by real fixture data rather than by taste. `cargo-audit` reports
241/// `chrono 0.4.19` as `RUSTSEC-2020-0159` and lists `RUSTSEC-2020-0071` under
242/// `related`; that alias sorts *first*, so naming the group from the alias set
243/// would label chrono's advisory with the id of the unrelated `time` one. An id
244/// no analyzer fired is not a name for what they found.
245///
246/// Preferring RUSTSEC is not favouritism towards Rust — it is that ADR-0018
247/// names it as *the* join key, and that where both analyzers report a Rust
248/// advisory it is the one identifier both of them publish.
249fn canonical(reports: &[Report]) -> String {
250    let mut fired: Vec<&str> = reports.iter().map(|r| r.rule.as_str()).collect();
251    fired.sort_unstable();
252    fired.dedup();
253    fired
254        .iter()
255        .find(|id| id.starts_with("RUSTSEC-"))
256        .or_else(|| fired.first())
257        .map(|id| (*id).to_owned())
258        .unwrap_or_default()
259}
260
261/// A dependency finding, reduced to what the join needs.
262struct Candidate<'a> {
263    analyzer: &'a str,
264    key: String,
265    rule: &'a str,
266    severity: Severity,
267    package: &'a str,
268    version: &'a str,
269    /// Every identifier this finding publishes for its advisory, including the
270    /// rule id itself.
271    identifiers: Vec<String>,
272}
273
274impl<'a> Candidate<'a> {
275    /// A candidate, or `None` if the finding is not on the dependency axis.
276    fn of(analyzer: &'a str, finding: &'a Finding) -> Option<Self> {
277        let package = finding.meta.get("package")?.as_str()?;
278        let version = finding.meta.get("version")?.as_str()?;
279        if package.is_empty() || version.is_empty() {
280            return None;
281        }
282        let mut identifiers = vec![finding.rule.clone()];
283        // `aliases` is what both adapters call the set; `related` is where
284        // `cargo-audit` puts a CVE that RustSec did not list as an alias, and
285        // `ids` is `osv-scanner`'s group membership. All three are identifiers
286        // an upstream published, so all three join.
287        for field in ["aliases", "related", "ids"] {
288            if let Some(values) = finding.meta.get(field).and_then(|v| v.as_array()) {
289                identifiers.extend(values.iter().filter_map(|v| v.as_str()).map(str::to_owned));
290            }
291        }
292        identifiers.retain(|id| !id.trim().is_empty());
293        identifiers.sort();
294        identifiers.dedup();
295        Some(Self {
296            analyzer,
297            key: finding.key.render(),
298            rule: &finding.rule,
299            severity: finding.severity.clone(),
300            package,
301            version,
302            identifiers,
303        })
304    }
305
306    /// Whether two findings name at least one identifier in common.
307    fn shares_identifier(&self, other: &Self) -> bool {
308        self.identifiers
309            .iter()
310            .any(|id| other.identifiers.binary_search(id).is_ok())
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::{Correspondence, cross_reference};
317    use rto_graph::{
318        AnalysisRun, CommandPolicy, EnvironmentPolicy, Finding, FindingKey, FindingsLayer,
319        Isolation, NetworkPolicy, RunnerKind, Severity, SourceIdentity, WorktreeAccess,
320    };
321
322    fn run(analyzer: &str) -> AnalysisRun {
323        AnalysisRun {
324            layer: format!("security:{analyzer}:ab12cd34"),
325            analyzer: analyzer.to_owned(),
326            analyzer_version: "1.0.0".to_owned(),
327            runner: RunnerKind::Ingested,
328            isolation: Isolation::Ingested,
329            image_digest: None,
330            rules_digest: None,
331            advisory_db: None,
332            command_policy: CommandPolicy {
333                network: NetworkPolicy::Deny,
334                worktree: WorktreeAccess::ReadOnly,
335                environment: EnvironmentPolicy::Scrubbed,
336            },
337            source: SourceIdentity::default(),
338            started_at: "2026-08-16T09:00:00Z".to_owned(),
339            ended_at: "2026-08-16T09:00:01Z".to_owned(),
340            exit_status: 1,
341            report_digest: "0".repeat(64),
342        }
343    }
344
345    fn finding(analyzer: &str, rule: &str, meta: serde_json::Value) -> Finding {
346        Finding {
347            key: FindingKey::new(analyzer, &[rule.to_owned()]).expect("key"),
348            rule: rule.to_owned(),
349            severity: Severity::High,
350            title: format!("{rule} is a problem"),
351            message: String::new(),
352            path: None,
353            span: None,
354            meta,
355        }
356    }
357
358    fn layer(analyzer: &str, findings: Vec<Finding>) -> FindingsLayer {
359        FindingsLayer {
360            run: run(analyzer),
361            findings,
362        }
363    }
364
365    /// The headline case: the same Rust advisory from both analyzers, named by
366    /// different ids, joined on the RUSTSEC id both of them publish. One
367    /// advisory, confirmed twice — and both keys still addressable.
368    #[test]
369    fn the_same_advisory_from_two_analyzers_is_one_confirmed_correspondence() {
370        let layers = vec![
371            layer(
372                "cargo-audit",
373                vec![finding(
374                    "cargo-audit",
375                    "RUSTSEC-2020-0071",
376                    serde_json::json!({
377                        "package": "time", "version": "0.2.22",
378                        "aliases": ["CVE-2020-26235"], "related": []
379                    }),
380                )],
381            ),
382            layer(
383                "osv-scanner",
384                vec![finding(
385                    "osv-scanner",
386                    "GHSA-wcg3-cvx6-7396",
387                    serde_json::json!({
388                        "package": "time", "version": "0.2.22",
389                        "aliases": ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"],
390                        "ids": ["GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"]
391                    }),
392                )],
393            ),
394        ];
395
396        let crossref = cross_reference(&layers);
397        assert_eq!(crossref.len(), 1, "one advisory, not two problems");
398        let one = &crossref[0];
399        assert_eq!(one.confirmed_by(), 2);
400        assert_eq!(one.analyzers(), vec!["cargo-audit", "osv-scanner"]);
401        // Named by the id ADR-0018 calls the join key.
402        assert_eq!(one.advisory, "RUSTSEC-2020-0071");
403        // Both keys survive: neither analyzer's finding is superseded here.
404        assert_eq!(one.keys().len(), 2);
405        assert!(one.keys().iter().any(|k| k.contains("cargo-audit")));
406        assert!(one.keys().iter().any(|k| k.contains("osv-scanner")));
407        // Each analyzer's own rule id is preserved, not rewritten to the
408        // canonical one.
409        let rules: Vec<&str> = one.reports.iter().map(|r| r.rule.as_str()).collect();
410        assert!(rules.contains(&"RUSTSEC-2020-0071"));
411        assert!(rules.contains(&"GHSA-wcg3-cvx6-7396"));
412    }
413
414    /// The transitive case, which is the one that actually happens: neither side
415    /// names an id the other names directly, and both name the same CVE.
416    #[test]
417    fn two_findings_join_through_a_shared_cve_neither_names_directly() {
418        let layers = vec![
419            layer(
420                "cargo-audit",
421                vec![finding(
422                    "cargo-audit",
423                    "RUSTSEC-2021-0001",
424                    serde_json::json!({
425                        "package": "widget", "version": "1.0.0",
426                        "aliases": [], "related": ["CVE-2021-9999"]
427                    }),
428                )],
429            ),
430            layer(
431                "osv-scanner",
432                vec![finding(
433                    "osv-scanner",
434                    "GHSA-aaaa-bbbb-cccc",
435                    serde_json::json!({
436                        "package": "widget", "version": "1.0.0",
437                        "aliases": ["CVE-2021-9999"]
438                    }),
439                )],
440            ),
441        ];
442        let crossref = cross_reference(&layers);
443        assert_eq!(crossref.len(), 1);
444        assert_eq!(crossref[0].confirmed_by(), 2);
445    }
446
447    /// The failure this join must not have. One CVE is regularly assigned to
448    /// several packages; joining on the identifier alone would fuse advisories
449    /// about different packages into one row.
450    #[test]
451    fn a_shared_identifier_on_different_packages_does_not_merge() {
452        let layers = vec![layer(
453            "osv-scanner",
454            vec![
455                finding(
456                    "osv-scanner",
457                    "GHSA-1",
458                    serde_json::json!({
459                        "package": "alpha", "version": "1.0.0", "aliases": ["CVE-2026-1"]
460                    }),
461                ),
462                finding(
463                    "osv-scanner",
464                    "GHSA-2",
465                    serde_json::json!({
466                        "package": "beta", "version": "1.0.0", "aliases": ["CVE-2026-1"]
467                    }),
468                ),
469            ],
470        )];
471        let crossref = cross_reference(&layers);
472        assert_eq!(crossref.len(), 2, "different packages stay different rows");
473    }
474
475    /// The same package at two versions is two advisories to fix, not one.
476    ///
477    /// The two findings deliberately share an advisory id: without that, they
478    /// would stay apart because nothing joins them, and this test would pass
479    /// whether or not the version were part of the bucket. A monorepo pinning
480    /// one library at two versions is the real case, and each pin is its own fix.
481    #[test]
482    fn the_same_advisory_at_two_versions_does_not_merge() {
483        let layers = vec![layer(
484            "osv-scanner",
485            vec![
486                finding(
487                    "osv-scanner",
488                    "GHSA-1",
489                    serde_json::json!({
490                        "package": "lodash", "version": "4.17.15", "aliases": ["CVE-2020-8203"]
491                    }),
492                ),
493                finding(
494                    "osv-scanner",
495                    "GHSA-1b",
496                    serde_json::json!({
497                        "package": "lodash", "version": "4.17.20", "aliases": ["CVE-2020-8203"]
498                    }),
499                ),
500            ],
501        )];
502        let crossref = cross_reference(&layers);
503        assert_eq!(crossref.len(), 2, "each pinned version is its own fix");
504        assert_eq!(crossref[0].version, "4.17.15");
505        assert_eq!(crossref[1].version, "4.17.20");
506    }
507
508    /// "Present in one, absent in the other" is a real state, not a defect: the
509    /// two analyzers pin their databases independently, and `yanked` is not an
510    /// advisory kind OSV can ever carry.
511    #[test]
512    fn an_advisory_only_one_analyzer_reports_is_a_normal_single_source_row() {
513        let layers = vec![
514            layer(
515                "cargo-audit",
516                vec![finding(
517                    "cargo-audit",
518                    "yanked",
519                    serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
520                )],
521            ),
522            layer(
523                "osv-scanner",
524                vec![finding(
525                    "osv-scanner",
526                    "GHSA-new",
527                    serde_json::json!({"package": "fresh", "version": "1.0.0"}),
528                )],
529            ),
530        ];
531        let crossref = cross_reference(&layers);
532        assert_eq!(crossref.len(), 2);
533        assert!(crossref.iter().all(|c| c.confirmed_by() == 1));
534        // Ordered by package: `fresh` before `half-baked`.
535        assert_eq!(crossref[0].package, "fresh");
536        assert_eq!(crossref[0].analyzers(), vec!["osv-scanner"]);
537        assert_eq!(crossref[1].package, "half-baked");
538        assert_eq!(crossref[1].analyzers(), vec!["cargo-audit"]);
539    }
540
541    /// The invariant ADR-0018 states in as many words: a cross-reference must
542    /// never be a count that silently halves. Every finding is still accounted
543    /// for after the join.
544    #[test]
545    fn no_finding_is_lost_or_double_counted_by_the_join() {
546        let layers = vec![
547            layer(
548                "cargo-audit",
549                vec![
550                    finding(
551                        "cargo-audit",
552                        "RUSTSEC-2020-0071",
553                        serde_json::json!({
554                            "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
555                        }),
556                    ),
557                    finding(
558                        "cargo-audit",
559                        "yanked",
560                        serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
561                    ),
562                ],
563            ),
564            layer(
565                "osv-scanner",
566                vec![finding(
567                    "osv-scanner",
568                    "RUSTSEC-2020-0071",
569                    serde_json::json!({
570                        "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
571                    }),
572                )],
573            ),
574        ];
575        let total: usize = layers.iter().map(|l| l.findings.len()).sum();
576        let crossref = cross_reference(&layers);
577        let reported: usize = crossref.iter().map(|c| c.reports.len()).sum();
578        assert_eq!(reported, total, "every finding appears exactly once");
579        assert_eq!(total, 3);
580        assert_eq!(crossref.len(), 2, "…across two advisories");
581    }
582
583    /// A SAST finding is not on the dependency axis, so there is nothing for a
584    /// dependency scanner to agree with and it does not take part.
585    #[test]
586    fn sast_findings_are_not_cross_referenced() {
587        let layers = vec![layer(
588            "semgrep",
589            vec![finding(
590                "semgrep",
591                "roteiro.python.eval-of-input",
592                serde_json::json!({"engine": "python"}),
593            )],
594        )];
595        assert!(cross_reference(&layers).is_empty());
596    }
597
598    #[test]
599    fn nothing_ingested_cross_references_to_nothing() {
600        assert!(cross_reference(&[]).is_empty());
601    }
602
603    /// A stable order, so two renderings of the same store are identical.
604    #[test]
605    fn the_order_is_stable_and_does_not_depend_on_layer_order() {
606        let a = layer(
607            "cargo-audit",
608            vec![finding(
609                "cargo-audit",
610                "R-1",
611                serde_json::json!({"package": "zeta", "version": "1.0.0"}),
612            )],
613        );
614        let b = layer(
615            "osv-scanner",
616            vec![finding(
617                "osv-scanner",
618                "G-1",
619                serde_json::json!({"package": "alpha", "version": "1.0.0"}),
620            )],
621        );
622        let forwards = cross_reference(&[a.clone(), b.clone()]);
623        let backwards = cross_reference(&[b, a]);
624        assert_eq!(forwards, backwards);
625        let packages: Vec<&str> = forwards.iter().map(|c| c.package.as_str()).collect();
626        assert_eq!(packages, vec!["alpha", "zeta"]);
627    }
628
629    /// A correspondence with no identifiers at all still names itself, rather
630    /// than rendering as a blank row.
631    #[test]
632    fn an_advisory_always_has_a_name() {
633        let layers = vec![layer(
634            "osv-scanner",
635            vec![finding(
636                "osv-scanner",
637                "OSV-1",
638                serde_json::json!({"package": "x", "version": "1.0.0"}),
639            )],
640        )];
641        let crossref: Vec<Correspondence> = cross_reference(&layers);
642        assert_eq!(crossref[0].advisory, "OSV-1");
643    }
644}