Skip to main content

chio_listing/
util.rs

1//! Shared validation, normalization, and report-aggregation helpers for generic listings.
2
3use std::collections::BTreeMap;
4
5use crate::crypto::sha256_hex;
6use crate::{
7    canonical_json_bytes, GenericListingArtifact, GenericListingDivergence,
8    GenericListingFreshnessState, GenericListingQuery, GenericListingReplicaFreshness,
9    GenericListingReport, GenericListingSearchError, GenericListingSearchPolicy,
10    GenericListingSearchResponse, GenericListingSearchResult, GenericListingStatus,
11    GenericNamespaceOwnership, GenericRegistryPublisher, GenericRegistryPublisherRole,
12    SignedGenericListing, GENERIC_LISTING_NETWORK_SEARCH_SCHEMA, GENERIC_LISTING_REPORT_SCHEMA,
13};
14
15pub fn normalize_namespace(namespace: &str) -> String {
16    namespace.trim().trim_end_matches('/').to_string()
17}
18
19pub(crate) fn generic_listing_body_sha256(
20    listing: &SignedGenericListing,
21) -> Result<String, String> {
22    Ok(sha256_hex(
23        &canonical_json_bytes(&listing.body).map_err(|error| error.to_string())?,
24    ))
25}
26
27pub fn ensure_generic_listing_signed_by_namespace_owner(
28    listing: &SignedGenericListing,
29    label: &str,
30) -> Result<(), String> {
31    listing.body.validate()?;
32    if !listing
33        .verify_signature()
34        .map_err(|error| error.to_string())?
35    {
36        return Err(format!("{label} signature is invalid"));
37    }
38    if listing.signer_key != listing.body.namespace_ownership.signer_public_key {
39        return Err(format!(
40            "{label} signer does not match the declared namespace ownership signer"
41        ));
42    }
43    Ok(())
44}
45
46pub fn ensure_generic_listing_namespace_consistency<'a>(
47    listings: impl IntoIterator<Item = &'a GenericListingArtifact>,
48) -> Result<(), String> {
49    let mut namespaces = BTreeMap::<String, GenericNamespaceOwnership>::new();
50    for listing in listings {
51        let namespace = normalize_namespace(&listing.namespace);
52        if namespace.is_empty() {
53            return Err("generic listing namespace must not be empty".to_string());
54        }
55        let ownership = listing.namespace_ownership.clone();
56        if let Some(existing) = namespaces.get(&namespace) {
57            if existing.owner_id != ownership.owner_id
58                || existing.registry_url != ownership.registry_url
59                || existing.signer_public_key != ownership.signer_public_key
60            {
61                return Err(format!(
62                    "generic listing namespace `{namespace}` has conflicting ownership claims"
63                ));
64            }
65        } else {
66            namespaces.insert(namespace, ownership);
67        }
68    }
69    Ok(())
70}
71
72pub fn aggregate_generic_listing_reports(
73    reports: &[GenericListingReport],
74    query: &GenericListingQuery,
75    now: u64,
76) -> GenericListingSearchResponse {
77    let normalized_query = query.normalized();
78    let mut reachable_count = 0_u64;
79    let mut stale_peer_count = 0_u64;
80    let mut errors = Vec::<GenericListingSearchError>::new();
81    let mut candidates = Vec::<(
82        SignedGenericListing,
83        GenericRegistryPublisher,
84        GenericListingReplicaFreshness,
85    )>::new();
86
87    for report in reports {
88        if let Err(error) = validate_generic_listing_report(report) {
89            errors.push(GenericListingSearchError {
90                operator_id: report.publisher.operator_id.clone(),
91                operator_name: report.publisher.operator_name.clone(),
92                registry_url: report.publisher.registry_url.clone(),
93                error,
94            });
95            continue;
96        }
97
98        let freshness = report.freshness.assess(report.generated_at, now);
99        if freshness.state == GenericListingFreshnessState::Stale {
100            stale_peer_count += 1;
101            errors.push(GenericListingSearchError {
102                operator_id: report.publisher.operator_id.clone(),
103                operator_name: report.publisher.operator_name.clone(),
104                registry_url: report.publisher.registry_url.clone(),
105                error: format!(
106                    "generic registry report is stale: age {}s exceeds max {}s",
107                    freshness.age_secs, freshness.max_age_secs
108                ),
109            });
110            continue;
111        }
112
113        reachable_count += 1;
114        for listing in &report.listings {
115            if normalized_query
116                .namespace
117                .as_deref()
118                .is_some_and(|namespace| normalize_namespace(&listing.body.namespace) != namespace)
119            {
120                continue;
121            }
122            if normalized_query
123                .actor_kind
124                .is_some_and(|actor_kind| listing.body.subject.actor_kind != actor_kind)
125            {
126                continue;
127            }
128            if normalized_query
129                .actor_id
130                .as_deref()
131                .is_some_and(|actor_id| listing.body.subject.actor_id != actor_id)
132            {
133                continue;
134            }
135            if normalized_query
136                .status
137                .is_some_and(|status| listing.body.status != status)
138            {
139                continue;
140            }
141            candidates.push((listing.clone(), report.publisher.clone(), freshness.clone()));
142        }
143    }
144
145    let mut groups = BTreeMap::<
146        String,
147        Vec<(
148            SignedGenericListing,
149            GenericRegistryPublisher,
150            GenericListingReplicaFreshness,
151        )>,
152    >::new();
153    for candidate in candidates {
154        let divergence_key = generic_listing_divergence_key(&candidate.0.body);
155        groups.entry(divergence_key).or_default().push(candidate);
156    }
157
158    let mut divergences = Vec::<GenericListingDivergence>::new();
159    let mut results = Vec::<GenericListingSearchResult>::new();
160
161    for (divergence_key, mut group) in groups {
162        let first = &group[0].0.body;
163        let canonical_fingerprint = (
164            first.compatibility.source_artifact_sha256.clone(),
165            first.status,
166            first.namespace_ownership.owner_id.clone(),
167            first.namespace_ownership.registry_url.clone(),
168        );
169        let is_divergent = group.iter().skip(1).any(|(listing, _, _)| {
170            (
171                listing.body.compatibility.source_artifact_sha256.clone(),
172                listing.body.status,
173                listing.body.namespace_ownership.owner_id.clone(),
174                listing.body.namespace_ownership.registry_url.clone(),
175            ) != canonical_fingerprint
176        });
177        if is_divergent {
178            divergences.push(GenericListingDivergence {
179                divergence_key,
180                actor_id: first.subject.actor_id.clone(),
181                actor_kind: first.subject.actor_kind,
182                publisher_operator_ids: group
183                    .iter()
184                    .map(|(_, publisher, _)| publisher.operator_id.clone())
185                    .collect(),
186                reason:
187                    "conflicting source artifact, lifecycle state, or namespace ownership across publishers"
188                        .to_string(),
189            });
190            continue;
191        }
192
193        group.sort_by(|left, right| {
194            freshness_state_rank(&left.2.state)
195                .cmp(&freshness_state_rank(&right.2.state))
196                .then(publisher_role_rank(left.1.role).cmp(&publisher_role_rank(right.1.role)))
197                .then(left.2.age_secs.cmp(&right.2.age_secs))
198                .then((u64::MAX - left.2.generated_at).cmp(&(u64::MAX - right.2.generated_at)))
199                .then(status_rank(left.0.body.status).cmp(&status_rank(right.0.body.status)))
200                .then(
201                    left.0
202                        .body
203                        .subject
204                        .actor_kind
205                        .cmp(&right.0.body.subject.actor_kind),
206                )
207                .then(
208                    left.0
209                        .body
210                        .subject
211                        .actor_id
212                        .cmp(&right.0.body.subject.actor_id),
213                )
214                .then(right.0.body.published_at.cmp(&left.0.body.published_at))
215                .then(left.1.operator_id.cmp(&right.1.operator_id))
216                .then(left.0.body.listing_id.cmp(&right.0.body.listing_id))
217        });
218
219        let (listing, publisher, freshness) = group.remove(0);
220        results.push(GenericListingSearchResult {
221            rank: 0,
222            listing,
223            publisher,
224            freshness,
225            replica_operator_ids: group
226                .iter()
227                .map(|(_, publisher, _)| publisher.operator_id.clone())
228                .collect(),
229        });
230    }
231
232    results.sort_by(|left, right| {
233        freshness_state_rank(&left.freshness.state)
234            .cmp(&freshness_state_rank(&right.freshness.state))
235            .then(
236                publisher_role_rank(left.publisher.role)
237                    .cmp(&publisher_role_rank(right.publisher.role)),
238            )
239            .then(left.freshness.age_secs.cmp(&right.freshness.age_secs))
240            .then(
241                (u64::MAX - left.freshness.generated_at)
242                    .cmp(&(u64::MAX - right.freshness.generated_at)),
243            )
244            .then(
245                status_rank(left.listing.body.status).cmp(&status_rank(right.listing.body.status)),
246            )
247            .then(
248                left.listing
249                    .body
250                    .subject
251                    .actor_kind
252                    .cmp(&right.listing.body.subject.actor_kind),
253            )
254            .then(
255                left.listing
256                    .body
257                    .subject
258                    .actor_id
259                    .cmp(&right.listing.body.subject.actor_id),
260            )
261            .then(
262                right
263                    .listing
264                    .body
265                    .published_at
266                    .cmp(&left.listing.body.published_at),
267            )
268            .then(left.publisher.operator_id.cmp(&right.publisher.operator_id))
269            .then(
270                left.listing
271                    .body
272                    .listing_id
273                    .cmp(&right.listing.body.listing_id),
274            )
275    });
276
277    for (index, result) in results.iter_mut().enumerate() {
278        result.rank = (index + 1) as u64;
279    }
280    results.truncate(normalized_query.limit_or_default());
281
282    GenericListingSearchResponse {
283        schema: GENERIC_LISTING_NETWORK_SEARCH_SCHEMA.to_string(),
284        generated_at: now,
285        query: normalized_query,
286        search_policy: GenericListingSearchPolicy::default(),
287        peer_count: reports.len() as u64,
288        reachable_count,
289        stale_peer_count,
290        divergence_count: divergences.len() as u64,
291        result_count: results.len() as u64,
292        results,
293        divergences,
294        errors,
295    }
296}
297
298fn validate_generic_listing_report(report: &GenericListingReport) -> Result<(), String> {
299    if report.schema != GENERIC_LISTING_REPORT_SCHEMA {
300        return Err(format!(
301            "unsupported generic listing report schema: {}",
302            report.schema
303        ));
304    }
305    report.namespace.validate()?;
306    report.publisher.validate()?;
307    report.freshness.validate(report.generated_at)?;
308    report.search_policy.validate()?;
309    ensure_generic_listing_namespace_consistency(
310        report.listings.iter().map(|listing| &listing.body),
311    )?;
312    for listing in &report.listings {
313        listing.body.validate()?;
314        if !listing
315            .verify_signature()
316            .map_err(|error| error.to_string())?
317        {
318            return Err(format!(
319                "listing `{}` signature is invalid in generic registry report",
320                listing.body.listing_id
321            ));
322        }
323        if listing.signer_key != listing.body.namespace_ownership.signer_public_key {
324            return Err(format!(
325                "listing `{}` signer does not match the declared namespace ownership signer",
326                listing.body.listing_id
327            ));
328        }
329        if normalize_namespace(&listing.body.namespace)
330            != normalize_namespace(&report.namespace.namespace)
331        {
332            return Err(format!(
333                "listing namespace `{}` falls outside report namespace `{}`",
334                listing.body.namespace, report.namespace.namespace
335            ));
336        }
337    }
338    Ok(())
339}
340
341fn generic_listing_divergence_key(listing: &GenericListingArtifact) -> String {
342    format!(
343        "{:?}:{}:{}:{}",
344        listing.subject.actor_kind,
345        listing.subject.actor_id,
346        listing.compatibility.source_schema,
347        listing.compatibility.source_artifact_id
348    )
349}
350
351fn publisher_role_rank(role: GenericRegistryPublisherRole) -> u8 {
352    match role {
353        GenericRegistryPublisherRole::Origin => 0,
354        GenericRegistryPublisherRole::Mirror => 1,
355        GenericRegistryPublisherRole::Indexer => 2,
356    }
357}
358
359fn status_rank(status: GenericListingStatus) -> u8 {
360    match status {
361        GenericListingStatus::Active => 0,
362        GenericListingStatus::Suspended => 1,
363        GenericListingStatus::Superseded => 2,
364        GenericListingStatus::Revoked => 3,
365        GenericListingStatus::Retired => 4,
366    }
367}
368
369fn freshness_state_rank(state: &GenericListingFreshnessState) -> u8 {
370    match state {
371        GenericListingFreshnessState::Fresh => 0,
372        GenericListingFreshnessState::Stale => 1,
373        GenericListingFreshnessState::Divergent => 2,
374    }
375}
376
377pub(crate) fn bounded_listing_limit(limit: Option<usize>, max: usize) -> usize {
378    limit.unwrap_or(100).clamp(1, max)
379}
380
381pub(crate) fn validate_non_empty(value: &str, field: &str) -> Result<(), String> {
382    if value.trim().is_empty() {
383        return Err(format!("{field} must not be empty"));
384    }
385    Ok(())
386}
387
388pub(crate) fn validate_http_url(value: &str, field: &str) -> Result<(), String> {
389    validate_non_empty(value, field)?;
390    if !(value.starts_with("http://") || value.starts_with("https://")) {
391        return Err(format!("{field} must start with http:// or https://"));
392    }
393    Ok(())
394}
395
396pub(crate) fn validate_optional_http_url(value: Option<&str>, field: &str) -> Result<(), String> {
397    if let Some(value) = value {
398        validate_http_url(value, field)?;
399    }
400    Ok(())
401}
402
403#[cfg(test)]
404mod tests {
405    use super::bounded_listing_limit;
406
407    #[test]
408    fn bounded_listing_limit_clamps_default_and_edges() {
409        assert_eq!(bounded_listing_limit(None, 200), 100);
410        assert_eq!(bounded_listing_limit(Some(0), 200), 1);
411        assert_eq!(bounded_listing_limit(Some(75), 200), 75);
412        assert_eq!(bounded_listing_limit(Some(500), 200), 200);
413    }
414}