1use 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}