1use crate::{Dependencies, Description, PluginName, SchemaError, TriggerType};
4use serde::Serialize as _;
5use serde::ser::Error as _;
6use std::fmt;
7use std::str::FromStr;
8use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
9use unicode_normalization::UnicodeNormalization;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct IndexSchemaVersion {
15 major: u32,
16 minor: u32,
17}
18
19impl IndexSchemaVersion {
20 pub const CURRENT_MAJOR: u32 = 2;
21 pub const CURRENT_MINOR: u32 = 1;
22 pub const CURRENT: Self = Self {
23 major: Self::CURRENT_MAJOR,
24 minor: Self::CURRENT_MINOR,
25 };
26
27 pub fn new(major: u32, minor: u32) -> Self {
28 Self { major, minor }
29 }
30 pub fn major(&self) -> u32 {
31 self.major
32 }
33 pub fn minor(&self) -> u32 {
34 self.minor
35 }
36}
37
38impl fmt::Display for IndexSchemaVersion {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 write!(f, "{}.{}", self.major, self.minor)
41 }
42}
43
44impl FromStr for IndexSchemaVersion {
45 type Err = SchemaError;
46 fn from_str(s: &str) -> Result<Self, Self::Err> {
47 let malformed = || SchemaError::MalformedSchemaVersion {
48 value: s.to_owned(),
49 };
50 let (major_str, minor_str) = s.split_once('.').ok_or_else(malformed)?;
51 if major_str.is_empty() || minor_str.is_empty() || minor_str.contains('.') {
52 return Err(malformed());
53 }
54 let major: u32 = major_str.parse().map_err(|_| malformed())?;
55 let minor: u32 = minor_str.parse().map_err(|_| malformed())?;
56 if major != Self::CURRENT_MAJOR {
57 return Err(SchemaError::UnsupportedIndexMajor {
58 found: s.to_owned(),
59 supported: Self::CURRENT_MAJOR,
60 });
61 }
62 Ok(Self { major, minor })
63 }
64}
65
66impl<'de> serde::Deserialize<'de> for IndexSchemaVersion {
67 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
68 where
69 D: serde::Deserializer<'de>,
70 {
71 let raw = String::deserialize(deserializer)?;
72 Self::from_str(&raw).map_err(serde::de::Error::custom)
73 }
74}
75
76impl serde::Serialize for IndexSchemaVersion {
77 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
78 where
79 S: serde::Serializer,
80 {
81 serializer.collect_str(self)
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88pub struct ArtifactsUrl(url::Url);
89
90impl ArtifactsUrl {
91 pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
92 let url = crate::identity::parse_registry_scheme_url(raw, |url, scheme| {
93 SchemaError::UnsupportedArtifactScheme { url, scheme }
94 })?;
95 Ok(Self(url))
96 }
97
98 pub fn as_url(&self) -> &url::Url {
99 &self.0
100 }
101
102 pub fn artifact_url(&self, name: &PluginName, version: &semver::Version) -> url::Url {
112 let filename = format!("{}-{}.tar.gz", name.as_str(), version);
113 let mut url = self.0.clone();
114 url.path_segments_mut()
115 .expect("ArtifactsUrl schemes (http/https/file) are always base-able")
116 .pop_if_empty()
117 .push(&filename);
118 url
119 }
120}
121
122impl fmt::Display for ArtifactsUrl {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 self.0.fmt(f)
125 }
126}
127
128impl<'de> serde::Deserialize<'de> for ArtifactsUrl {
129 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
130 where
131 D: serde::Deserializer<'de>,
132 {
133 let raw = String::deserialize(deserializer)?;
134 Self::try_new(&raw).map_err(serde::de::Error::custom)
135 }
136}
137
138impl serde::Serialize for ArtifactsUrl {
139 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
140 where
141 S: serde::Serializer,
142 {
143 serializer.collect_str(&self.0)
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub struct ArtifactHash(String);
151
152impl ArtifactHash {
153 pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
154 const PREFIX: &str = "sha256:";
155 let invalid = || SchemaError::InvalidHash {
156 value: raw.to_owned(),
157 };
158 let Some(hex) = raw.strip_prefix(PREFIX) else {
159 return Err(invalid());
160 };
161 if hex.len() != 64
162 || !hex
163 .chars()
164 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
165 {
166 return Err(invalid());
167 }
168 Ok(Self(raw.to_owned()))
169 }
170
171 pub fn as_str(&self) -> &str {
172 &self.0
173 }
174}
175
176impl fmt::Display for ArtifactHash {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 f.write_str(&self.0)
179 }
180}
181
182impl<'de> serde::Deserialize<'de> for ArtifactHash {
183 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
184 where
185 D: serde::Deserializer<'de>,
186 {
187 let raw = String::deserialize(deserializer)?;
188 Self::try_new(&raw).map_err(serde::de::Error::custom)
189 }
190}
191
192impl serde::Serialize for ArtifactHash {
193 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
194 where
195 S: serde::Serializer,
196 {
197 serializer.serialize_str(&self.0)
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
206pub struct PublishedAt(String);
207
208impl PublishedAt {
209 const LEN: usize = "YYYY-MM-DDTHH:MM:SSZ".len();
210
211 pub fn try_new(raw: &str) -> Result<Self, SchemaError> {
212 if !has_cargo_pubtime_shape(raw) {
213 return Err(SchemaError::InvalidPublishedAt {
214 value: raw.to_owned(),
215 });
216 }
217
218 let year = raw[0..4].parse::<i32>().expect("shape checked digits");
219 let month = raw[5..7].parse::<u8>().expect("shape checked digits");
220 let day = raw[8..10].parse::<u8>().expect("shape checked digits");
221 let hour = raw[11..13].parse::<u8>().expect("shape checked digits");
222 let minute = raw[14..16].parse::<u8>().expect("shape checked digits");
223 let second = raw[17..19].parse::<u8>().expect("shape checked digits");
224
225 let month = Month::try_from(month).map_err(|_| SchemaError::InvalidPublishedAt {
226 value: raw.to_owned(),
227 })?;
228 let date = Date::from_calendar_date(year, month, day).map_err(|_| {
229 SchemaError::InvalidPublishedAt {
230 value: raw.to_owned(),
231 }
232 })?;
233 let time =
234 Time::from_hms(hour, minute, second).map_err(|_| SchemaError::InvalidPublishedAt {
235 value: raw.to_owned(),
236 })?;
237 let _ = PrimitiveDateTime::new(date, time).assume_utc();
238
239 Ok(Self(raw.to_owned()))
240 }
241
242 pub fn now_utc() -> Self {
243 Self::from_utc_datetime(OffsetDateTime::now_utc())
244 .expect("current UTC timestamp must fit Cargo pubtime format")
245 }
246
247 pub fn from_utc_datetime(timestamp: OffsetDateTime) -> Result<Self, SchemaError> {
248 let timestamp = timestamp.to_offset(UtcOffset::UTC);
249 let year = timestamp.year();
250 if !(0..=9999).contains(&year) {
251 return Err(SchemaError::InvalidPublishedAt {
252 value: year.to_string(),
253 });
254 }
255
256 let value = format!(
257 "{year:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
258 u8::from(timestamp.month()),
259 timestamp.day(),
260 timestamp.hour(),
261 timestamp.minute(),
262 timestamp.second()
263 );
264 Ok(Self(value))
265 }
266
267 pub fn as_str(&self) -> &str {
268 &self.0
269 }
270}
271
272fn has_cargo_pubtime_shape(value: &str) -> bool {
273 let bytes = value.as_bytes();
274 bytes.len() == PublishedAt::LEN
275 && bytes[4] == b'-'
276 && bytes[7] == b'-'
277 && bytes[10] == b'T'
278 && bytes[13] == b':'
279 && bytes[16] == b':'
280 && bytes[19] == b'Z'
281 && bytes
282 .iter()
283 .enumerate()
284 .all(|(idx, byte)| matches!(idx, 4 | 7 | 10 | 13 | 16 | 19) || byte.is_ascii_digit())
285}
286
287impl fmt::Display for PublishedAt {
288 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289 f.write_str(&self.0)
290 }
291}
292
293impl FromStr for PublishedAt {
294 type Err = SchemaError;
295
296 fn from_str(s: &str) -> Result<Self, Self::Err> {
297 Self::try_new(s)
298 }
299}
300
301impl<'de> serde::Deserialize<'de> for PublishedAt {
302 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
303 where
304 D: serde::Deserializer<'de>,
305 {
306 let raw = String::deserialize(deserializer)?;
307 Self::try_new(&raw).map_err(serde::de::Error::custom)
308 }
309}
310
311impl serde::Serialize for PublishedAt {
312 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
313 where
314 S: serde::Serializer,
315 {
316 serializer.serialize_str(&self.0)
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
322pub struct Index {
323 pub index_schema_version: IndexSchemaVersion,
324 pub artifacts_url: ArtifactsUrl,
325 pub plugins: Vec<IndexEntry>,
326}
327
328#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
330pub struct IndexEntry {
331 pub name: PluginName,
332 pub version: semver::Version,
333 pub published_at: PublishedAt,
334 pub description: Description,
335 pub triggers: Vec<TriggerType>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub homepage: Option<url::Url>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub repository: Option<url::Url>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub documentation: Option<url::Url>,
342 pub dependencies: Dependencies,
343 pub hash: ArtifactHash,
344 #[serde(default, skip_serializing_if = "is_false")]
345 pub yanked: bool,
346}
347
348impl IndexEntry {
349 pub fn from_manifest(manifest: crate::Manifest, hash: ArtifactHash) -> Self {
350 Self::from_manifest_with_published_at(manifest, hash, PublishedAt::now_utc())
351 }
352
353 pub fn from_manifest_with_published_at(
354 manifest: crate::Manifest,
355 hash: ArtifactHash,
356 published_at: PublishedAt,
357 ) -> Self {
358 let plugin = manifest.plugin;
359 Self {
360 name: plugin.name,
361 version: plugin.version,
362 published_at,
363 description: plugin.description,
364 triggers: plugin.triggers,
365 homepage: plugin.homepage,
366 repository: plugin.repository,
367 documentation: plugin.documentation,
368 dependencies: manifest.dependencies,
369 hash,
370 yanked: false,
371 }
372 }
373}
374
375fn is_false(b: &bool) -> bool {
376 !*b
377}
378
379impl Index {
380 pub fn parse_json(input: &str) -> Result<Self, crate::SchemaErrors> {
401 use crate::raw::RawIndex;
402 use crate::{FieldPath, ReportedError, SchemaErrors};
403 use std::collections::HashMap;
404 use std::str::FromStr;
405
406 let raw: RawIndex = serde_json::from_str(input)
407 .map_err(|source| SchemaErrors::single_at_root(SchemaError::JsonParse { source }))?;
408
409 let schema_version =
410 IndexSchemaVersion::from_str(&raw.index_schema_version).map_err(|e| {
411 SchemaErrors::new(vec![ReportedError::new(
412 FieldPath::root().field("index_schema_version"),
413 e,
414 )])
415 })?;
416
417 let mut errors = Vec::new();
418 let root = FieldPath::root();
419
420 let artifacts_url = match ArtifactsUrl::try_new(&raw.artifacts_url) {
421 Ok(u) => Some(u),
422 Err(e) => {
423 errors.push(ReportedError::new(root.field("artifacts_url"), e));
424 None
425 }
426 };
427
428 let mut entries_ok: Vec<IndexEntry> = Vec::with_capacity(raw.plugins.len());
429
430 let mut seen_by_canonical: HashMap<String, Vec<(String, String)>> = HashMap::new();
436
437 for (i, raw_entry) in raw.plugins.iter().enumerate() {
438 let entry_path = root.field("plugins").index(i);
439 let validated = validate_raw_entry(raw_entry, &entry_path, &mut errors);
440
441 let canonical = crate::identity::canonical_name(&raw_entry.name);
442 let existing = seen_by_canonical.entry(canonical.clone()).or_default();
443
444 let exact_dup = existing
445 .iter()
446 .any(|(n, v)| n == &raw_entry.name && v == &raw_entry.version);
447 if exact_dup {
448 errors.push(ReportedError::new(
449 entry_path.clone(),
450 SchemaError::DuplicateIndexEntry {
451 name: raw_entry.name.clone(),
452 version: raw_entry.version.clone(),
453 },
454 ));
455 } else if existing.iter().any(|(n, _)| n != &raw_entry.name) {
456 errors.push(ReportedError::new(
460 entry_path.clone(),
461 SchemaError::CanonicalCollision {
462 name: raw_entry.name.clone(),
463 canonical: canonical.clone(),
464 existing: existing.clone(),
465 },
466 ));
467 }
468
469 existing.push((raw_entry.name.clone(), raw_entry.version.clone()));
470
471 if let Some(entry) = validated {
472 entries_ok.push(entry);
473 }
474 }
475
476 if !errors.is_empty() {
477 return Err(SchemaErrors::new(errors));
478 }
479
480 Ok(Index {
481 index_schema_version: schema_version,
482 artifacts_url: artifacts_url.unwrap(),
483 plugins: entries_ok,
484 })
485 }
486
487 pub fn to_canonical_json(&self) -> Result<String, SchemaError> {
498 let mut normalized = self.clone();
499 normalized.index_schema_version = IndexSchemaVersion::CURRENT;
503 normalized.plugins.sort_by(|a, b| {
506 a.name
507 .as_str()
508 .cmp(b.name.as_str())
509 .then_with(|| a.version.cmp_precedence(&b.version))
510 });
511 for entry in &mut normalized.plugins {
514 let nfc = normalize_nfc(entry.description.as_str());
515 entry.description = Description::try_new(&nfc)?;
516 }
517
518 let mut buf = Vec::with_capacity(256);
519 let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
520 let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
521 normalized
522 .serialize(&mut ser)
523 .map_err(|source| SchemaError::JsonSerialize { source })?;
524 buf.push(b'\n');
525 String::from_utf8(buf).map_err(|e| SchemaError::JsonSerialize {
526 source: serde_json::Error::custom(e.to_string()),
527 })
528 }
529
530 pub fn check_entry_insert(&self, entry: &IndexEntry) -> Result<(), crate::IndexInsertError> {
538 use crate::IndexInsertError;
539
540 let new_canonical = entry.name.canonical();
541
542 let existing_canonical: Vec<(String, semver::Version)> = self
543 .plugins
544 .iter()
545 .filter(|e| e.name.canonical() == new_canonical)
546 .map(|e| (e.name.as_str().to_owned(), e.version.clone()))
547 .collect();
548
549 let any_spelling_differs = existing_canonical
550 .iter()
551 .any(|(n, _)| n != entry.name.as_str());
552 if any_spelling_differs {
553 return Err(IndexInsertError::CanonicalCollision {
554 name: entry.name.as_str().to_owned(),
555 canonical: new_canonical,
556 existing: existing_canonical,
557 });
558 }
559
560 let same_version_dup = existing_canonical.iter().any(|(_, v)| v == &entry.version);
561 if same_version_dup {
562 let existing_versions: Vec<semver::Version> =
563 existing_canonical.iter().map(|(_, v)| v.clone()).collect();
564 return Err(IndexInsertError::Duplicate {
565 name: entry.name.as_str().to_owned(),
566 version: entry.version.clone(),
567 existing_versions,
568 });
569 }
570
571 Ok(())
572 }
573
574 pub fn push_entry(&mut self, entry: IndexEntry) -> Result<(), crate::IndexInsertError> {
580 self.check_entry_insert(&entry)?;
581 self.plugins.push(entry);
582 Ok(())
583 }
584}
585
586fn normalize_nfc(s: &str) -> String {
587 s.nfc().collect()
588}
589
590fn validate_raw_entry(
593 raw: &crate::raw::RawIndexEntry,
594 path: &crate::FieldPath,
595 errors: &mut Vec<crate::ReportedError>,
596) -> Option<IndexEntry> {
597 use crate::manifest::parse_optional_http_url_from_path;
598 use crate::{Description, PluginName, PythonRequirement, ReportedError, TriggerType};
599 use std::str::FromStr;
600
601 let local_err_count = errors.len();
602
603 let name = match PluginName::from_str(&raw.name) {
604 Ok(n) => Some(n),
605 Err(e) => {
606 errors.push(ReportedError::new(path.field("name"), e));
607 None
608 }
609 };
610
611 let version = match semver::Version::parse(&raw.version) {
612 Ok(v) => Some(v),
613 Err(source) => {
614 errors.push(ReportedError::new(
615 path.field("version"),
616 SchemaError::InvalidVersion {
617 version: raw.version.clone(),
618 source,
619 },
620 ));
621 None
622 }
623 };
624
625 let published_at = match &raw.published_at {
626 Some(serde_json::Value::String(value)) => match PublishedAt::try_new(value) {
627 Ok(published_at) => Some(published_at),
628 Err(e) => {
629 errors.push(ReportedError::new(path.field("published_at"), e));
630 None
631 }
632 },
633 Some(value) => {
634 errors.push(ReportedError::new(
635 path.field("published_at"),
636 SchemaError::InvalidPublishedAt {
637 value: value.to_string(),
638 },
639 ));
640 None
641 }
642 None => {
643 errors.push(ReportedError::new(
644 path.field("published_at"),
645 SchemaError::InvalidPublishedAt {
646 value: "<missing>".to_owned(),
647 },
648 ));
649 None
650 }
651 };
652
653 let description = match Description::try_new(&raw.description) {
654 Ok(d) => Some(d),
655 Err(e) => {
656 errors.push(ReportedError::new(path.field("description"), e));
657 None
658 }
659 };
660
661 let mut triggers_ok: Vec<TriggerType> = Vec::with_capacity(raw.triggers.len());
662 if raw.triggers.is_empty() {
663 errors.push(ReportedError::new(
664 path.field("triggers"),
665 SchemaError::EmptyTriggers,
666 ));
667 } else {
668 for (i, t) in raw.triggers.iter().enumerate() {
669 match TriggerType::from_str(t) {
670 Ok(tt) => triggers_ok.push(tt),
671 Err(e) => errors.push(ReportedError::new(path.field("triggers").index(i), e)),
672 }
673 }
674 }
675
676 let homepage = parse_optional_http_url_from_path(&raw.homepage, errors, path, "homepage");
677 let repository = parse_optional_http_url_from_path(&raw.repository, errors, path, "repository");
678 let documentation =
679 parse_optional_http_url_from_path(&raw.documentation, errors, path, "documentation");
680
681 let database_version = match semver::VersionReq::parse(&raw.dependencies.database_version) {
682 Ok(r) => Some(r),
683 Err(source) => {
684 errors.push(ReportedError::new(
685 path.field("dependencies").field("database_version"),
686 SchemaError::InvalidDatabaseVersion {
687 range: raw.dependencies.database_version.clone(),
688 source,
689 },
690 ));
691 None
692 }
693 };
694
695 let mut python_ok: Vec<PythonRequirement> = Vec::with_capacity(raw.dependencies.python.len());
696 for (i, p) in raw.dependencies.python.iter().enumerate() {
697 match PythonRequirement::try_new(p) {
698 Ok(pr) => python_ok.push(pr),
699 Err(e) => errors.push(ReportedError::new(
700 path.field("dependencies").field("python").index(i),
701 e,
702 )),
703 }
704 }
705
706 let plugins_ok = crate::manifest::validate_raw_plugin_dependencies(
707 &raw.dependencies.plugins,
708 &path.field("dependencies"),
709 errors,
710 );
711
712 let hash = match ArtifactHash::try_new(&raw.hash) {
713 Ok(h) => Some(h),
714 Err(e) => {
715 errors.push(ReportedError::new(path.field("hash"), e));
716 None
717 }
718 };
719
720 if errors.len() > local_err_count {
721 return None;
722 }
723
724 Some(IndexEntry {
725 name: name.unwrap(),
726 version: version.unwrap(),
727 published_at: published_at.unwrap(),
728 description: description.unwrap(),
729 triggers: triggers_ok,
730 homepage,
731 repository,
732 documentation,
733 dependencies: crate::Dependencies {
734 database_version: database_version.unwrap(),
735 python: python_ok,
736 plugins: plugins_ok,
737 },
738 hash: hash.unwrap(),
739 yanked: raw.yanked,
740 })
741}
742
743#[cfg(test)]
744mod schema_version_tests {
745 use super::*;
746 use assert_matches::assert_matches;
747
748 #[test]
749 fn parses_supported_major() {
750 let v: IndexSchemaVersion = "2.0".parse().unwrap();
751 assert_eq!(v.major(), 2);
752 assert_eq!(v.minor(), 0);
753 }
754
755 #[test]
756 fn rejects_unsupported_major() {
757 let err = "3.0".parse::<IndexSchemaVersion>().unwrap_err();
758 assert_matches!(err, SchemaError::UnsupportedIndexMajor { .. });
759 }
760
761 #[test]
762 fn rejects_malformed() {
763 assert_matches!(
764 "abc".parse::<IndexSchemaVersion>(),
765 Err(SchemaError::MalformedSchemaVersion { .. })
766 );
767 }
768
769 #[test]
770 fn current_uses_declared_major_and_minor_constants() {
771 assert_eq!(
772 IndexSchemaVersion::CURRENT.major(),
773 IndexSchemaVersion::CURRENT_MAJOR
774 );
775 assert_eq!(
776 IndexSchemaVersion::CURRENT.minor(),
777 IndexSchemaVersion::CURRENT_MINOR
778 );
779 }
780
781 #[test]
782 fn current_to_string_round_trips() {
783 let s = IndexSchemaVersion::CURRENT.to_string();
784 let parsed: IndexSchemaVersion = s.parse().unwrap();
785 assert_eq!(parsed, IndexSchemaVersion::CURRENT);
786 }
787
788 #[test]
789 fn current_serializes_as_schema_two_one() {
790 assert_eq!(IndexSchemaVersion::CURRENT.to_string(), "2.1");
791 }
792}
793
794#[cfg(test)]
795mod artifacts_url_tests {
796 use super::*;
797 use assert_matches::assert_matches;
798 use rstest::rstest;
799
800 #[rstest]
801 #[case("https://plugins.example/artifacts")]
802 #[case("http://localhost:8080/artifacts")]
803 #[case("file:///srv/plugins")]
804 fn valid_schemes_accepted(#[case] input: &str) {
805 assert!(ArtifactsUrl::try_new(input).is_ok());
806 }
807
808 #[rstest]
809 #[case("oci://registry.example")]
810 #[case("s3://bucket/plugins")]
811 #[case("git://example/plugins")]
812 #[case("git+https://example/plugins")]
813 #[case("git+ssh://example/plugins")]
814 #[case("ftp://example/plugins")]
815 #[case("sftp://example/plugins")]
816 fn rejected_schemes(#[case] input: &str) {
817 let err = ArtifactsUrl::try_new(input).unwrap_err();
818 assert_matches!(err, SchemaError::UnsupportedArtifactScheme { .. });
819 }
820
821 #[test]
822 fn malformed_url_rejected() {
823 let err = ArtifactsUrl::try_new("not a url").unwrap_err();
824 assert_matches!(err, SchemaError::InvalidUrl { .. });
825 }
826
827 fn name(input: &str) -> PluginName {
828 use std::str::FromStr;
829 PluginName::from_str(input).expect("valid plugin name in test")
830 }
831
832 fn version(input: &str) -> semver::Version {
833 semver::Version::parse(input).expect("valid semver in test")
834 }
835
836 #[rstest]
837 #[case(
839 "https://example.com/artifacts",
840 "n",
841 "1.2.3",
842 "https://example.com/artifacts/n-1.2.3.tar.gz"
843 )]
844 #[case(
845 "https://example.com/artifacts/",
846 "n",
847 "1.2.3",
848 "https://example.com/artifacts/n-1.2.3.tar.gz"
849 )]
850 #[case(
852 "file:///path/to/registry",
853 "n",
854 "1.2.3",
855 "file:///path/to/registry/n-1.2.3.tar.gz"
856 )]
857 #[case(
858 "file:///path/to/registry/",
859 "n",
860 "1.2.3",
861 "file:///path/to/registry/n-1.2.3.tar.gz"
862 )]
863 #[case(
865 "https://host/a/b/c",
866 "n",
867 "1.2.3",
868 "https://host/a/b/c/n-1.2.3.tar.gz"
869 )]
870 #[case(
871 "https://host/a/b/c/",
872 "n",
873 "1.2.3",
874 "https://host/a/b/c/n-1.2.3.tar.gz"
875 )]
876 #[case(
878 "https://example.com/artifacts",
879 "foo-bar",
880 "1.2.3",
881 "https://example.com/artifacts/foo-bar-1.2.3.tar.gz"
882 )]
883 #[case(
884 "https://example.com/artifacts",
885 "foo_bar",
886 "1.2.3",
887 "https://example.com/artifacts/foo_bar-1.2.3.tar.gz"
888 )]
889 #[case(
891 "https://example.com/artifacts",
892 "n",
893 "1.2.3-alpha.1",
894 "https://example.com/artifacts/n-1.2.3-alpha.1.tar.gz"
895 )]
896 #[case(
897 "https://example.com/artifacts",
898 "n",
899 "1.2.3+build.7",
900 "https://example.com/artifacts/n-1.2.3+build.7.tar.gz"
901 )]
902 #[case(
904 "https://example.com/artifacts?channel=stable",
905 "n",
906 "1.2.3",
907 "https://example.com/artifacts/n-1.2.3.tar.gz?channel=stable"
908 )]
909 #[case(
910 "https://example.com/artifacts#deploy",
911 "n",
912 "1.2.3",
913 "https://example.com/artifacts/n-1.2.3.tar.gz#deploy"
914 )]
915 #[case(
916 "https://example.com/artifacts?t=1#x",
917 "n",
918 "1.2.3",
919 "https://example.com/artifacts/n-1.2.3.tar.gz?t=1#x"
920 )]
921 #[case(
923 "http://localhost:8080/artifacts",
924 "n",
925 "1.2.3",
926 "http://localhost:8080/artifacts/n-1.2.3.tar.gz"
927 )]
928 #[case(
930 "https://u:p@host/path",
931 "n",
932 "1.2.3",
933 "https://u:p@host/path/n-1.2.3.tar.gz"
934 )]
935 #[case(
937 "file://server/share/plugins",
938 "n",
939 "1.2.3",
940 "file://server/share/plugins/n-1.2.3.tar.gz"
941 )]
942 #[case(
944 "https://xn--n3h.example/a",
945 "n",
946 "1.2.3",
947 "https://xn--n3h.example/a/n-1.2.3.tar.gz"
948 )]
949 #[case("https://host", "n", "1.2.3", "https://host/n-1.2.3.tar.gz")]
951 fn artifact_url_composition(
952 #[case] base: &str,
953 #[case] name_input: &str,
954 #[case] version_input: &str,
955 #[case] expected: &str,
956 ) {
957 let base = ArtifactsUrl::try_new(base).expect("valid base URL in test");
958 let url = base.artifact_url(&name(name_input), &version(version_input));
959 assert_eq!(url.as_str(), expected);
960 }
961}
962
963#[cfg(test)]
964mod artifact_hash_tests {
965 use super::*;
966 use assert_matches::assert_matches;
967
968 #[test]
969 fn valid_hash_accepted() {
970 let h = ArtifactHash::try_new(
971 "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
972 )
973 .unwrap();
974 assert_eq!(h.as_str().len(), "sha256:".len() + 64);
975 }
976
977 #[test]
978 fn wrong_prefix_rejected() {
979 assert_matches!(
980 ArtifactHash::try_new(
981 "sha512:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
982 ),
983 Err(SchemaError::InvalidHash { .. })
984 );
985 }
986
987 #[test]
988 fn wrong_length_rejected() {
989 assert_matches!(
990 ArtifactHash::try_new("sha256:abc"),
991 Err(SchemaError::InvalidHash { .. })
992 );
993 }
994
995 #[test]
996 fn uppercase_hex_rejected() {
997 assert_matches!(
998 ArtifactHash::try_new(
999 "sha256:9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08"
1000 ),
1001 Err(SchemaError::InvalidHash { .. })
1002 );
1003 }
1004
1005 #[rstest::rstest]
1008 #[case("sha256:g000000000000000000000000000000000000000000000000000000000000000")]
1009 #[case("sha256:z000000000000000000000000000000000000000000000000000000000000000")]
1010 #[case("sha256:!000000000000000000000000000000000000000000000000000000000000000")]
1011 #[case("sha256: 000000000000000000000000000000000000000000000000000000000000000")]
1012 #[case("sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00z08")]
1013 fn non_hex_chars_rejected(#[case] input: &str) {
1014 assert_matches!(
1015 ArtifactHash::try_new(input),
1016 Err(SchemaError::InvalidHash { .. })
1017 );
1018 }
1019}
1020
1021#[cfg(test)]
1022mod published_at_tests {
1023 use super::*;
1024 use assert_matches::assert_matches;
1025 use rstest::rstest;
1026
1027 #[test]
1028 fn valid_cargo_pubtime_format_is_accepted() {
1029 let published_at = PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap();
1030 assert_eq!(published_at.as_str(), "2026-04-29T18:45:12Z");
1031 assert_eq!(published_at.to_string(), "2026-04-29T18:45:12Z");
1032 }
1033
1034 #[test]
1035 fn from_utc_datetime_formats_as_cargo_pubtime_in_utc() {
1036 let source_offset = UtcOffset::from_hms(-5, 0, 0).unwrap();
1037 let source_datetime = PrimitiveDateTime::new(
1038 Date::from_calendar_date(2026, Month::April, 29).unwrap(),
1039 Time::from_hms_nano(13, 45, 12, 987_654_321).unwrap(),
1040 )
1041 .assume_offset(source_offset);
1042
1043 let published_at = PublishedAt::from_utc_datetime(source_datetime).unwrap();
1044
1045 assert_eq!(published_at.as_str(), "2026-04-29T18:45:12Z");
1046 }
1047
1048 #[rstest]
1049 #[case("2026-04-29T18:45:12.123Z")]
1050 #[case("2026-04-29T13:45:12-05:00")]
1051 #[case("2026-04-29T18:45:12+00:00")]
1052 #[case("2026-4-29T18:45:12Z")]
1053 #[case("2026-04-29t18:45:12Z")]
1054 #[case("2026-04-29T18:45:12z")]
1055 #[case("2026-04-29 18:45:12Z")]
1056 #[case("2026-02-30T18:45:12Z")]
1057 #[case("2026-04-29T24:00:00Z")]
1058 fn invalid_cargo_pubtime_format_is_rejected(#[case] input: &str) {
1059 assert_matches!(
1060 PublishedAt::try_new(input),
1061 Err(SchemaError::InvalidPublishedAt { .. })
1062 );
1063 }
1064}
1065
1066#[cfg(test)]
1067mod index_tests {
1068 use super::*;
1069 use assert_matches::assert_matches;
1070 use pretty_assertions::assert_eq;
1071
1072 const MINIMAL: &str = r#"{
1073 "index_schema_version": "2.0",
1074 "artifacts_url": "https://plugins.example.com/artifacts",
1075 "plugins": [
1076 {
1077 "name": "downsampler",
1078 "version": "1.2.0",
1079 "published_at": "2026-04-29T18:45:12Z",
1080 "description": "Test plugin",
1081 "triggers": ["process_writes"],
1082 "dependencies": {
1083 "database_version": ">=3.2.0,<4.0.0",
1084 "python": []
1085 },
1086 "hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
1087 }
1088 ]
1089}"#;
1090
1091 fn minimal_with_published_at() -> String {
1092 MINIMAL.to_owned()
1093 }
1094
1095 fn minimal_without_published_at() -> String {
1096 MINIMAL.replace(
1097 r#" "published_at": "2026-04-29T18:45:12Z",
1098"#,
1099 "",
1100 )
1101 }
1102
1103 #[test]
1104 fn parses_published_at_per_entry() {
1105 let idx = Index::parse_json(&minimal_with_published_at()).unwrap();
1106 assert_eq!(idx.plugins[0].published_at.as_str(), "2026-04-29T18:45:12Z");
1107 }
1108
1109 #[test]
1110 fn missing_published_at_rejected_per_entry() {
1111 let errors = Index::parse_json(&minimal_without_published_at()).unwrap_err();
1112 assert_eq!(errors.errors().len(), 1);
1113 assert_matches!(
1114 errors.errors()[0].error,
1115 SchemaError::InvalidPublishedAt { .. }
1116 );
1117 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1118 }
1119
1120 #[test]
1121 fn non_string_published_at_rejected_per_entry() {
1122 let src = minimal_with_published_at().replace(
1123 r#""published_at": "2026-04-29T18:45:12Z""#,
1124 r#""published_at": 123"#,
1125 );
1126 let errors = Index::parse_json(&src).unwrap_err();
1127 assert_eq!(errors.errors().len(), 1);
1128 assert_matches!(
1129 errors.errors()[0].error,
1130 SchemaError::InvalidPublishedAt { .. }
1131 );
1132 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1133 }
1134
1135 #[test]
1136 fn malformed_published_at_rejected_per_entry() {
1137 let src =
1138 minimal_with_published_at().replace("2026-04-29T18:45:12Z", "2026-04-29T18:45:12.123Z");
1139 let errors = Index::parse_json(&src).unwrap_err();
1140 assert_eq!(errors.errors().len(), 1);
1141 assert_matches!(
1142 errors.errors()[0].error,
1143 SchemaError::InvalidPublishedAt { .. }
1144 );
1145 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].published_at");
1146 }
1147
1148 #[test]
1149 fn parses_minimal_index() {
1150 let idx = Index::parse_json(&minimal_with_published_at()).unwrap();
1151 assert_eq!(idx.plugins.len(), 1);
1152 assert_eq!(idx.plugins[0].name.as_str(), "downsampler");
1153 }
1154
1155 #[test]
1156 fn yanked_absent_means_not_yanked() {
1157 let idx = Index::parse_json(MINIMAL).unwrap();
1158 assert!(!idx.plugins[0].yanked);
1159 }
1160
1161 #[test]
1162 fn yanked_true_parsed() {
1163 let src = MINIMAL.replace(r#""hash": "#, r#""yanked": true, "hash": "#);
1164 let idx = Index::parse_json(&src).unwrap();
1165 assert!(idx.plugins[0].yanked);
1166 }
1167
1168 #[test]
1169 fn yanked_false_parsed() {
1170 let src = MINIMAL.replace(r#""hash": "#, r#""yanked": false, "hash": "#);
1171 let idx = Index::parse_json(&src).unwrap();
1172 assert!(!idx.plugins[0].yanked);
1173 }
1174
1175 #[rstest::rstest]
1180 #[case::not_json("not json")]
1181 #[case::trailing_garbage(r#"{"index_schema_version": "2.0"} extra"#)]
1182 #[case::root_is_array("[]")]
1183 #[case::root_is_number("42")]
1184 #[case::root_is_string(r#""hello""#)]
1185 #[case::missing_artifacts_url(r#"{"index_schema_version": "2.0", "plugins": []}"#)]
1186 #[case::missing_plugins(
1187 r#"{"index_schema_version": "2.0", "artifacts_url": "https://example.com/a"}"#
1188 )]
1189 fn malformed_json_short_circuits(#[case] input: &str) {
1190 let errors = Index::parse_json(input).unwrap_err();
1191 assert_eq!(
1192 errors.errors().len(),
1193 1,
1194 "expected single root-level error, got {:?}",
1195 errors.errors()
1196 );
1197 assert_matches!(errors.errors()[0].error, SchemaError::JsonParse { .. });
1198 assert_eq!(
1199 errors.errors()[0].path.as_str(),
1200 "",
1201 "expected root path, got {:?}",
1202 errors.errors()[0].path.as_str()
1203 );
1204 }
1205
1206 #[test]
1207 fn duplicate_name_version_rejected() {
1208 let dup = r#"{
1209 "index_schema_version": "2.0",
1210 "artifacts_url": "https://plugins.example.com/artifacts",
1211 "plugins": [
1212 { "name": "x", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1213 "dependencies": {"database_version":">=3.0.0","python":[]},
1214 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1215 { "name": "x", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1216 "dependencies": {"database_version":">=3.0.0","python":[]},
1217 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1218 ]
1219}"#;
1220 let errors = Index::parse_json(dup).unwrap_err();
1221 assert_eq!(errors.errors().len(), 1);
1222 assert_matches!(
1223 errors.errors()[0].error,
1224 SchemaError::DuplicateIndexEntry { .. }
1225 );
1226 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1227 }
1228
1229 #[test]
1230 fn index_rejects_hyphen_underscore_collision() {
1231 let json = r#"{
1234 "index_schema_version": "2.0",
1235 "artifacts_url": "https://plugins.example.com/artifacts",
1236 "plugins": [
1237 { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1238 "dependencies": {"database_version":">=3.0.0","python":[]},
1239 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1240 { "name": "foo_bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1241 "dependencies": {"database_version":">=3.0.0","python":[]},
1242 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1243 ]
1244}"#;
1245 let errors = Index::parse_json(json).expect_err("should reject canonical collision");
1246 assert_eq!(errors.errors().len(), 1);
1247 assert_matches!(
1248 errors.errors()[0].error,
1249 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1250 if name == "foo_bar"
1251 && canonical == "foo_bar"
1252 && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1253 );
1254 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1255 }
1256
1257 #[test]
1258 fn index_rejects_case_collision() {
1259 let json = r#"{
1261 "index_schema_version": "2.0",
1262 "artifacts_url": "https://plugins.example.com/artifacts",
1263 "plugins": [
1264 { "name": "MyPlugin", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1265 "dependencies": {"database_version":">=3.0.0","python":[]},
1266 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1267 { "name": "myplugin", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1268 "dependencies": {"database_version":">=3.0.0","python":[]},
1269 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1270 ]
1271}"#;
1272 let errors = Index::parse_json(json).expect_err("should reject case collision");
1273 assert_eq!(errors.errors().len(), 1);
1274 assert_matches!(
1275 errors.errors()[0].error,
1276 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1277 if name == "myplugin"
1278 && canonical == "myplugin"
1279 && existing == &vec![("MyPlugin".to_owned(), "1.0.0".to_owned())]
1280 );
1281 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1282 }
1283
1284 #[test]
1285 fn index_rejects_three_way_canonical_collision() {
1286 let json = r#"{
1289 "index_schema_version": "2.0",
1290 "artifacts_url": "https://plugins.example.com/artifacts",
1291 "plugins": [
1292 { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1293 "dependencies": {"database_version":">=3.0.0","python":[]},
1294 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1295 { "name": "foo_bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1296 "dependencies": {"database_version":">=3.0.0","python":[]},
1297 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" },
1298 { "name": "FOO-BAR", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x3", "triggers": ["process_writes"],
1299 "dependencies": {"database_version":">=3.0.0","python":[]},
1300 "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" }
1301 ]
1302}"#;
1303 let errors = Index::parse_json(json).expect_err("should reject two collisions");
1304 let e = errors.errors();
1305 assert_eq!(
1306 e.len(),
1307 2,
1308 "expected 2 errors, got {}: {:?}",
1309 e.len(),
1310 e.iter()
1311 .map(|r| (r.path.as_str(), &r.error))
1312 .collect::<Vec<_>>()
1313 );
1314 assert_matches!(
1315 e[0].error,
1316 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1317 if name == "foo_bar"
1318 && canonical == "foo_bar"
1319 && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1320 );
1321 assert_eq!(e[0].path.as_str(), "plugins[1]");
1322 assert_matches!(
1323 e[1].error,
1324 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1325 if name == "FOO-BAR"
1326 && canonical == "foo_bar"
1327 && existing == &vec![
1328 ("foo-bar".to_owned(), "1.0.0".to_owned()),
1329 ("foo_bar".to_owned(), "1.0.0".to_owned()),
1330 ]
1331 );
1332 assert_eq!(e[1].path.as_str(), "plugins[2]");
1333 }
1334
1335 #[test]
1336 fn index_rejects_canonical_collision_across_versions() {
1337 let json = r#"{
1340 "index_schema_version": "2.0",
1341 "artifacts_url": "https://plugins.example.com/artifacts",
1342 "plugins": [
1343 { "name": "foo-bar", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "x", "triggers": ["process_writes"],
1344 "dependencies": {"database_version":">=3.0.0","python":[]},
1345 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" },
1346 { "name": "foo_bar", "version": "1.0.1", "published_at": "2026-04-29T18:45:12Z", "description": "x2", "triggers": ["process_writes"],
1347 "dependencies": {"database_version":">=3.0.0","python":[]},
1348 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }
1349 ]
1350}"#;
1351 let errors =
1352 Index::parse_json(json).expect_err("should reject canonical collision across versions");
1353 assert_eq!(errors.errors().len(), 1);
1354 assert_matches!(
1355 errors.errors()[0].error,
1356 SchemaError::CanonicalCollision { ref name, ref canonical, ref existing }
1357 if name == "foo_bar"
1358 && canonical == "foo_bar"
1359 && existing == &vec![("foo-bar".to_owned(), "1.0.0".to_owned())]
1360 );
1361 assert_eq!(errors.errors()[0].path.as_str(), "plugins[1]");
1362 }
1363
1364 #[test]
1365 fn ignores_unknown_per_entry_field() {
1366 let with_unknown =
1367 MINIMAL.replace(r#""hash": "#, r#""experimental_tag": "beta", "hash": "#);
1368 assert!(Index::parse_json(&with_unknown).is_ok());
1369 }
1370
1371 #[test]
1374 fn empty_triggers_rejected_per_entry() {
1375 let src = MINIMAL.replace(r#""triggers": ["process_writes"]"#, r#""triggers": []"#);
1376 let errors = Index::parse_json(&src).unwrap_err();
1377 assert_eq!(errors.errors().len(), 1);
1378 assert_matches!(errors.errors()[0].error, SchemaError::EmptyTriggers);
1379 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].triggers");
1380 }
1381
1382 #[test]
1383 fn invalid_homepage_scheme_rejected_per_entry() {
1384 let src = MINIMAL.replace(r#""hash": "#, r#""homepage": "ftp://bad/", "hash": "#);
1385 let errors = Index::parse_json(&src).unwrap_err();
1386 assert_eq!(errors.errors().len(), 1);
1387 assert_matches!(
1388 errors.errors()[0].error,
1389 SchemaError::InvalidUrlScheme { .. }
1390 );
1391 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].homepage");
1392 }
1393
1394 #[test]
1395 fn invalid_repository_scheme_rejected_per_entry() {
1396 let src = MINIMAL.replace(r#""hash": "#, r#""repository": "git://bad/", "hash": "#);
1397 let errors = Index::parse_json(&src).unwrap_err();
1398 assert_eq!(errors.errors().len(), 1);
1399 assert_matches!(
1400 errors.errors()[0].error,
1401 SchemaError::InvalidUrlScheme { .. }
1402 );
1403 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].repository");
1404 }
1405
1406 #[test]
1407 fn invalid_documentation_scheme_rejected_per_entry() {
1408 let src = MINIMAL.replace(
1409 r#""hash": "#,
1410 r#""documentation": "s3://bucket/docs", "hash": "#,
1411 );
1412 let errors = Index::parse_json(&src).unwrap_err();
1413 assert_eq!(errors.errors().len(), 1);
1414 assert_matches!(
1415 errors.errors()[0].error,
1416 SchemaError::InvalidUrlScheme { .. }
1417 );
1418 assert_eq!(errors.errors()[0].path.as_str(), "plugins[0].documentation");
1419 }
1420
1421 #[test]
1422 fn http_and_https_optional_urls_accepted() {
1423 let src = MINIMAL.replace(
1424 r#""hash": "#,
1425 r#""homepage": "http://example.com", "repository": "https://github.com/x/y", "hash": "#,
1426 );
1427 assert!(Index::parse_json(&src).is_ok());
1428 }
1429
1430 #[test]
1431 fn ignores_unknown_top_level_field() {
1432 let src = MINIMAL.replace(
1433 r#""artifacts_url":"#,
1434 r#""experimental_top_level": true, "artifacts_url":"#,
1435 );
1436 assert!(Index::parse_json(&src).is_ok());
1437 }
1438
1439 #[test]
1442 fn collects_multiple_entry_defects_and_duplicate() {
1443 let long_desc = "a".repeat(201);
1446 let json = format!(
1447 r#"{{
1448 "index_schema_version": "2.0",
1449 "artifacts_url": "https://plugins.example.com/artifacts",
1450 "plugins": [
1451 {{ "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1452 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1453 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }},
1454 {{ "name": "beta", "version": "2.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1455 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1456 "hash": "not-a-hash" }},
1457 {{ "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "ok", "triggers": ["process_writes"],
1458 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1459 "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }},
1460 {{ "name": "gamma", "version": "3.0.0", "published_at": "2026-04-29T18:45:12Z", "description": "{long_desc}", "triggers": ["process_writes"],
1461 "dependencies": {{"database_version":">=3.0.0","python":[]}},
1462 "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222" }}
1463 ]
1464}}"#
1465 );
1466
1467 let errors = Index::parse_json(&json).expect_err("should fail");
1468 let e = errors.errors();
1469 assert_eq!(
1470 e.len(),
1471 3,
1472 "expected 3 errors, got {}: {:?}",
1473 e.len(),
1474 e.iter()
1475 .map(|r| (r.path.as_str(), &r.error))
1476 .collect::<Vec<_>>()
1477 );
1478
1479 let paths: Vec<&str> = e.iter().map(|r| r.path.as_str()).collect();
1480 assert!(
1481 paths.contains(&"plugins[1].hash"),
1482 "missing hash path: {paths:?}"
1483 );
1484 assert!(
1485 paths.iter().any(|p| p.starts_with("plugins[2]")),
1486 "missing duplicate path: {paths:?}"
1487 );
1488 assert!(
1489 paths.contains(&"plugins[3].description"),
1490 "missing description path: {paths:?}"
1491 );
1492 }
1493
1494 #[test]
1495 fn collects_published_at_defect_with_other_entry_defects() {
1496 let json = r#"{
1497 "index_schema_version": "2.0",
1498 "artifacts_url": "https://plugins.example.com/artifacts",
1499 "plugins": [
1500 { "name": "alpha", "version": "1.0.0", "published_at": "2026-04-29T18:45:12.123Z", "description": "ok", "triggers": [],
1501 "dependencies": {"database_version":">=3.0.0","python":[]},
1502 "hash": "not-a-hash" }
1503 ]
1504}"#;
1505
1506 let errors = Index::parse_json(json).expect_err("should fail");
1507 let e = errors.errors();
1508 assert_eq!(e.len(), 3);
1509 assert!(
1510 e.iter().any(|reported| {
1511 reported.path.as_str() == "plugins[0].published_at"
1512 && matches!(reported.error, SchemaError::InvalidPublishedAt { .. })
1513 }),
1514 "missing InvalidPublishedAt at plugins[0].published_at: {e:?}"
1515 );
1516 assert!(
1517 e.iter().any(|reported| {
1518 reported.path.as_str() == "plugins[0].triggers"
1519 && matches!(reported.error, SchemaError::EmptyTriggers)
1520 }),
1521 "missing EmptyTriggers at plugins[0].triggers: {e:?}"
1522 );
1523 assert!(
1524 e.iter().any(|reported| {
1525 reported.path.as_str() == "plugins[0].hash"
1526 && matches!(reported.error, SchemaError::InvalidHash { .. })
1527 }),
1528 "missing InvalidHash at plugins[0].hash: {e:?}"
1529 );
1530 }
1531
1532 #[test]
1533 fn index_schema_version_mismatch_short_circuits() {
1534 let json = r#"{
1535 "index_schema_version": "99.0",
1536 "artifacts_url": "ftp://bad",
1537 "plugins": []
1538}"#;
1539 let errors = Index::parse_json(json).expect_err("should fail");
1540 assert_eq!(errors.errors().len(), 1);
1541 assert_matches::assert_matches!(
1542 errors.errors()[0].error,
1543 SchemaError::UnsupportedIndexMajor { .. }
1544 );
1545 }
1546
1547 #[test]
1548 fn old_non_empty_index_without_published_at_is_rejected_by_schema_version() {
1549 let json = r#"{
1550 "index_schema_version": "1.0",
1551 "artifacts_url": "https://plugins.example.com/artifacts",
1552 "plugins": [
1553 { "name": "alpha", "version": "1.0.0", "description": "ok", "triggers": ["process_writes"],
1554 "dependencies": {"database_version":">=3.0.0","python":[]},
1555 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" }
1556 ]
1557}"#;
1558
1559 let errors = Index::parse_json(json).expect_err("old schema should fail");
1560 assert_eq!(errors.errors().len(), 1);
1561 assert_eq!(errors.errors()[0].path.as_str(), "index_schema_version");
1562 assert_matches!(
1563 errors.errors()[0].error,
1564 SchemaError::UnsupportedIndexMajor { ref found, supported }
1565 if found == "1.0" && supported == 2
1566 );
1567 }
1568}
1569
1570#[cfg(test)]
1571mod canonical_serialization_tests {
1572 use super::*;
1573 use pretty_assertions::assert_eq;
1574
1575 fn make_entry(name: &str, version: semver::Version) -> IndexEntry {
1576 IndexEntry {
1577 name: name.parse().unwrap(),
1578 version,
1579 published_at: PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap(),
1580 description: Description::try_new("desc").unwrap(),
1581 triggers: vec![TriggerType::ProcessWrites],
1582 homepage: None,
1583 repository: None,
1584 documentation: None,
1585 dependencies: Dependencies {
1586 database_version: ">=3.0.0".parse().unwrap(),
1587 python: vec![],
1588 plugins: vec![],
1589 },
1590 hash: ArtifactHash::try_new(
1591 "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1592 )
1593 .unwrap(),
1594 yanked: false,
1595 }
1596 }
1597
1598 #[test]
1599 fn plugins_sorted_by_name_then_version() {
1600 let idx = Index {
1601 index_schema_version: IndexSchemaVersion::CURRENT,
1602 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1603 plugins: vec![
1604 make_entry("zebra", semver::Version::new(1, 0, 0)),
1605 make_entry("alpha", semver::Version::new(2, 0, 0)),
1606 make_entry("alpha", semver::Version::new(1, 0, 0)),
1607 ],
1608 };
1609 let out = idx.to_canonical_json().unwrap();
1610 let alpha_1_pos = out.find("\"alpha\"").unwrap();
1611 let alpha_2_pos = out[alpha_1_pos + 1..].find("\"alpha\"").unwrap() + alpha_1_pos + 1;
1612 let zebra_pos = out.find("\"zebra\"").unwrap();
1613 assert!(alpha_1_pos < alpha_2_pos, "alpha 1.0 before alpha 2.0");
1614 assert!(alpha_2_pos < zebra_pos, "alpha before zebra");
1615 }
1616
1617 #[test]
1618 fn two_serialize_calls_produce_byte_identical() {
1619 let idx = Index {
1620 index_schema_version: IndexSchemaVersion::CURRENT,
1621 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1622 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1623 };
1624 let a = idx.to_canonical_json().unwrap();
1625 let b = idx.to_canonical_json().unwrap();
1626 assert_eq!(a, b);
1627 }
1628
1629 #[test]
1630 fn ends_with_newline() {
1631 let idx = Index {
1632 index_schema_version: IndexSchemaVersion::CURRENT,
1633 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1634 plugins: vec![],
1635 };
1636 let out = idx.to_canonical_json().unwrap();
1637 assert!(out.ends_with('\n'));
1638 }
1639
1640 #[test]
1641 fn two_space_indent() {
1642 let idx = Index {
1643 index_schema_version: IndexSchemaVersion::CURRENT,
1644 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1645 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1646 };
1647 let out = idx.to_canonical_json().unwrap();
1648 assert!(
1649 out.contains("\n \"index_schema_version\""),
1650 "expected 2-space indent at top level"
1651 );
1652 }
1653
1654 #[test]
1655 fn snapshot_locks_full_shape() {
1656 let idx = Index {
1657 index_schema_version: IndexSchemaVersion::CURRENT,
1658 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1659 plugins: vec![make_entry("alpha", semver::Version::new(1, 0, 0)), {
1660 let mut e = make_entry("beta", semver::Version::new(2, 1, 0));
1661 e.yanked = true;
1662 e.dependencies.plugins = vec![crate::PluginDependency {
1663 index_url: crate::IndexUrl::try_new("https://plugins.example.com/index.json")
1664 .unwrap(),
1665 name: "geo-lookup".parse().unwrap(),
1666 version: ">=1.0.0,<2.0.0".parse().unwrap(),
1667 }];
1668 e
1669 }],
1670 };
1671 insta::assert_snapshot!("canonical_full_shape", idx.to_canonical_json().unwrap());
1672 }
1673
1674 #[test]
1678 fn empty_plugin_dependencies_key_is_omitted() {
1679 let idx = Index {
1680 index_schema_version: IndexSchemaVersion::CURRENT,
1681 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1682 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1683 };
1684 let out = idx.to_canonical_json().unwrap();
1685 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1686 assert!(
1687 parsed["plugins"][0]["dependencies"]
1688 .get("plugins")
1689 .is_none(),
1690 "empty dependencies.plugins must be omitted, got: {}",
1691 parsed["plugins"][0]["dependencies"]
1692 );
1693 assert!(parsed["plugins"][0]["dependencies"].get("python").is_some());
1695 }
1696
1697 #[test]
1701 fn plugin_dependency_index_url_serializes_normalized() {
1702 let idx = Index {
1703 index_schema_version: IndexSchemaVersion::CURRENT,
1704 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1705 plugins: vec![{
1706 let mut e = make_entry("x", semver::Version::new(1, 0, 0));
1707 e.dependencies.plugins = vec![crate::PluginDependency {
1708 index_url: crate::IndexUrl::try_new(
1709 "HTTPS://Plugins.EXAMPLE.com:443/index.json",
1710 )
1711 .unwrap(),
1712 name: "geo-lookup".parse().unwrap(),
1713 version: ">=1.0.0".parse().unwrap(),
1714 }];
1715 e
1716 }],
1717 };
1718 let out = idx.to_canonical_json().unwrap();
1719 assert!(
1720 out.contains("\"index_url\": \"https://plugins.example.com/index.json\""),
1721 "index_url must serialize in normalized form, got: {out}"
1722 );
1723 }
1724
1725 #[test]
1729 fn canonical_write_stamps_current_version_on_legacy_index() {
1730 let legacy = r#"{
1731 "index_schema_version": "2.0",
1732 "artifacts_url": "https://example.com/artifacts",
1733 "plugins": [
1734 {
1735 "name": "downsampler",
1736 "version": "1.2.0",
1737 "published_at": "2026-04-29T18:45:12Z",
1738 "description": "desc",
1739 "triggers": ["process_writes"],
1740 "dependencies": { "database_version": ">=3.0.0", "python": [] },
1741 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1742 }
1743 ]
1744 }"#;
1745 let current = legacy.replace("\"2.0\"", "\"2.1\"");
1746
1747 let from_legacy = Index::parse_json(legacy)
1748 .unwrap()
1749 .to_canonical_json()
1750 .unwrap();
1751 let from_current = Index::parse_json(¤t)
1752 .unwrap()
1753 .to_canonical_json()
1754 .unwrap();
1755
1756 assert!(from_legacy.contains("\"index_schema_version\": \"2.1\""));
1757 assert_eq!(
1758 from_legacy, from_current,
1759 "legacy rewrite must differ only in the stamped version"
1760 );
1761 }
1762
1763 #[test]
1764 fn entry_field_order_is_canonical() {
1765 let idx = Index {
1766 index_schema_version: IndexSchemaVersion::CURRENT,
1767 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1768 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1769 };
1770 let out = idx.to_canonical_json().unwrap();
1771 let name_pos = out.find("\"name\"").unwrap();
1772 let version_pos = out.find("\"version\"").unwrap();
1773 let published_at_pos = out.find("\"published_at\"").unwrap();
1774 let description_pos = out.find("\"description\"").unwrap();
1775 let triggers_pos = out.find("\"triggers\"").unwrap();
1776 let dependencies_pos = out.find("\"dependencies\"").unwrap();
1777 let hash_pos = out.find("\"hash\"").unwrap();
1778 assert!(name_pos < version_pos);
1779 assert!(version_pos < published_at_pos);
1780 assert!(published_at_pos < description_pos);
1781 assert!(description_pos < triggers_pos);
1782 assert!(triggers_pos < dependencies_pos);
1783 assert!(dependencies_pos < hash_pos);
1784 }
1785
1786 #[test]
1787 fn published_at_is_preserved_exactly_in_canonical_json() {
1788 let mut entry = make_entry("x", semver::Version::new(1, 0, 0));
1789 entry.published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
1790 let idx = Index {
1791 index_schema_version: IndexSchemaVersion::CURRENT,
1792 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1793 plugins: vec![entry],
1794 };
1795 let out = idx.to_canonical_json().unwrap();
1796 assert!(out.contains(r#""published_at": "2027-01-02T03:04:05Z""#));
1797 }
1798
1799 #[test]
1800 fn published_at_does_not_affect_sort() {
1801 let mut alpha = make_entry("alpha", semver::Version::new(1, 0, 0));
1802 alpha.published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
1803 let mut beta = make_entry("beta", semver::Version::new(1, 0, 0));
1804 beta.published_at = PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap();
1805 let idx = Index {
1806 index_schema_version: IndexSchemaVersion::CURRENT,
1807 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1808 plugins: vec![beta, alpha],
1809 };
1810 let out = idx.to_canonical_json().unwrap();
1811 let alpha_pos = out.find("\"alpha\"").unwrap();
1812 let beta_pos = out.find("\"beta\"").unwrap();
1813 assert!(alpha_pos < beta_pos, "name sort must ignore published_at");
1814 }
1815
1816 #[test]
1817 fn parse_canonical_round_trip_is_idempotent() {
1818 let idx = Index {
1819 index_schema_version: IndexSchemaVersion::CURRENT,
1820 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1821 plugins: vec![make_entry("x", semver::Version::new(1, 0, 0))],
1822 };
1823 let first = idx.to_canonical_json().unwrap();
1824 let reparsed = Index::parse_json(&first).unwrap();
1825 let second = reparsed.to_canonical_json().unwrap();
1826 assert_eq!(first, second);
1827 }
1828
1829 #[test]
1830 fn nfc_normalization_makes_precomposed_equal_decomposed() {
1831 let precomposed = Description::try_new("caf\u{00E9}").unwrap();
1835 let decomposed = Description::try_new("cafe\u{0301}").unwrap();
1836 let entry_a = IndexEntry {
1837 description: precomposed,
1838 ..make_entry("x", semver::Version::new(1, 0, 0))
1839 };
1840 let entry_b = IndexEntry {
1841 description: decomposed,
1842 ..make_entry("x", semver::Version::new(1, 0, 0))
1843 };
1844
1845 let idx_a = Index {
1846 index_schema_version: IndexSchemaVersion::CURRENT,
1847 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1848 plugins: vec![entry_a],
1849 };
1850 let idx_b = Index {
1851 index_schema_version: IndexSchemaVersion::CURRENT,
1852 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1853 plugins: vec![entry_b],
1854 };
1855 assert_eq!(
1856 idx_a.to_canonical_json().unwrap(),
1857 idx_b.to_canonical_json().unwrap()
1858 );
1859 }
1860
1861 #[test]
1862 fn yank_status_does_not_affect_sort() {
1863 let mut yanked_alpha = make_entry("alpha", semver::Version::new(1, 0, 0));
1864 yanked_alpha.yanked = true;
1865 let idx = Index {
1866 index_schema_version: IndexSchemaVersion::CURRENT,
1867 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1868 plugins: vec![
1869 make_entry("beta", semver::Version::new(1, 0, 0)),
1870 yanked_alpha,
1871 ],
1872 };
1873 let out = idx.to_canonical_json().unwrap();
1874 let alpha_pos = out.find("\"alpha\"").unwrap();
1875 let beta_pos = out.find("\"beta\"").unwrap();
1876 assert!(
1877 alpha_pos < beta_pos,
1878 "yanked alpha should still sort before beta"
1879 );
1880 }
1881
1882 #[test]
1886 fn prerelease_sorts_before_release_at_same_major_minor_patch() {
1887 let prerelease = make_entry("p", "1.0.0-alpha".parse().unwrap());
1888 let release = make_entry("p", semver::Version::new(1, 0, 0));
1889 let idx = Index {
1890 index_schema_version: IndexSchemaVersion::CURRENT,
1891 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1892 plugins: vec![release, prerelease],
1894 };
1895 let out = idx.to_canonical_json().unwrap();
1896 let alpha_pos = out.find(r#""1.0.0-alpha""#).unwrap();
1897 let release_pos = out.find(r#""1.0.0""#).unwrap();
1898 assert!(
1899 alpha_pos < release_pos,
1900 "prerelease 1.0.0-alpha must sort before release 1.0.0"
1901 );
1902 }
1903
1904 #[test]
1909 fn build_metadata_does_not_affect_precedence() {
1910 let v_a: semver::Version = "1.0.0+build.1".parse().unwrap();
1911 let v_b: semver::Version = "1.0.0+build.2".parse().unwrap();
1912
1913 assert_eq!(v_a.cmp_precedence(&v_b), std::cmp::Ordering::Equal);
1914 assert_ne!(v_a.cmp(&v_b), std::cmp::Ordering::Equal);
1917
1918 let idx = Index {
1919 index_schema_version: IndexSchemaVersion::CURRENT,
1920 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1921 plugins: vec![make_entry("p", v_a.clone()), make_entry("p", v_b.clone())],
1923 };
1924 let out = idx.to_canonical_json().unwrap();
1925 let pos_a = out.find("1.0.0+build.1").unwrap();
1926 let pos_b = out.find("1.0.0+build.2").unwrap();
1927 assert!(
1928 pos_a < pos_b,
1929 "stable sort preserves input order for equal-precedence entries"
1930 );
1931 }
1932}
1933
1934#[cfg(test)]
1935mod insert_tests {
1936 use super::*;
1937 use crate::{
1938 ArtifactHash, ArtifactsUrl, Dependencies, Description, IndexInsertError, TriggerType,
1939 };
1940 use assert_matches::assert_matches;
1941
1942 fn empty_index() -> Index {
1943 Index {
1944 index_schema_version: IndexSchemaVersion::CURRENT,
1945 artifacts_url: ArtifactsUrl::try_new("https://example.com/artifacts").unwrap(),
1946 plugins: vec![],
1947 }
1948 }
1949
1950 fn make_entry(name: &str, version: semver::Version) -> IndexEntry {
1951 IndexEntry {
1952 name: name.parse().unwrap(),
1953 version,
1954 published_at: PublishedAt::try_new("2026-04-29T18:45:12Z").unwrap(),
1955 description: Description::try_new("desc").unwrap(),
1956 triggers: vec![TriggerType::ProcessWrites],
1957 homepage: None,
1958 repository: None,
1959 documentation: None,
1960 dependencies: Dependencies {
1961 database_version: ">=3.0.0".parse().unwrap(),
1962 python: vec![],
1963 plugins: vec![],
1964 },
1965 hash: ArtifactHash::try_new(
1966 "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1967 )
1968 .unwrap(),
1969 yanked: false,
1970 }
1971 }
1972
1973 #[test]
1974 fn empty_index_accepts_append() {
1975 let mut idx = empty_index();
1976 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1977 .unwrap();
1978 assert_eq!(idx.plugins.len(), 1);
1979 }
1980
1981 #[test]
1982 fn same_spelling_different_version_accepted() {
1983 let mut idx = empty_index();
1984 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1985 .unwrap();
1986 idx.push_entry(make_entry("alpha", semver::Version::new(1, 1, 0)))
1987 .unwrap();
1988 assert_eq!(idx.plugins.len(), 2);
1989 }
1990
1991 #[test]
1992 fn same_spelling_same_version_returns_duplicate() {
1993 let mut idx = empty_index();
1994 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1995 .unwrap();
1996 let err = idx
1997 .push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
1998 .unwrap_err();
1999 assert_matches!(err, IndexInsertError::Duplicate { .. });
2000 }
2001
2002 #[test]
2003 fn duplicate_error_lists_existing_versions_in_index_order() {
2004 let mut idx = empty_index();
2005 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2006 .unwrap();
2007 idx.push_entry(make_entry("alpha", semver::Version::new(1, 1, 0)))
2008 .unwrap();
2009 let err = idx
2010 .push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2011 .unwrap_err();
2012 match err {
2013 IndexInsertError::Duplicate {
2014 existing_versions, ..
2015 } => {
2016 assert_eq!(
2017 existing_versions,
2018 vec![semver::Version::new(1, 0, 0), semver::Version::new(1, 1, 0)]
2019 );
2020 }
2021 other => panic!("expected Duplicate, got {other:?}"),
2022 }
2023 }
2024
2025 #[test]
2026 fn hyphen_underscore_canonical_collision_rejected() {
2027 let mut idx = empty_index();
2028 idx.push_entry(make_entry("foo-bar", semver::Version::new(1, 0, 0)))
2029 .unwrap();
2030 let err = idx
2031 .push_entry(make_entry("foo_bar", semver::Version::new(1, 0, 0)))
2032 .unwrap_err();
2033 assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
2034 }
2035
2036 #[test]
2037 fn case_only_canonical_collision_rejected() {
2038 let mut idx = empty_index();
2039 idx.push_entry(make_entry("MyPlugin", semver::Version::new(1, 0, 0)))
2040 .unwrap();
2041 let err = idx
2042 .push_entry(make_entry("myplugin", semver::Version::new(1, 0, 0)))
2043 .unwrap_err();
2044 assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
2045 }
2046
2047 #[test]
2048 fn canonical_collision_rejected_even_when_version_differs() {
2049 let mut idx = empty_index();
2050 idx.push_entry(make_entry("foo-bar", semver::Version::new(1, 0, 0)))
2051 .unwrap();
2052 let err = idx
2053 .push_entry(make_entry("foo_bar", semver::Version::new(2, 0, 0)))
2054 .unwrap_err();
2055 assert_matches!(err, IndexInsertError::CanonicalCollision { .. });
2056 }
2057
2058 #[test]
2059 fn index_unchanged_after_failed_push_entry() {
2060 let mut idx = empty_index();
2061 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2062 .unwrap();
2063 let snapshot = idx.clone();
2064 let _ = idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)));
2065 assert_eq!(idx, snapshot);
2066 }
2067
2068 #[test]
2069 fn check_entry_insert_does_not_mutate() {
2070 let mut idx = empty_index();
2071 idx.push_entry(make_entry("alpha", semver::Version::new(1, 0, 0)))
2072 .unwrap();
2073 let snapshot = idx.clone();
2074 let entry = make_entry("alpha", semver::Version::new(2, 0, 0));
2075 idx.check_entry_insert(&entry).unwrap();
2076 assert_eq!(idx, snapshot);
2077 }
2078}
2079
2080#[cfg(test)]
2081mod from_manifest_tests {
2082 use super::*;
2083 use crate::{
2084 ArtifactHash, Dependencies, Description, Manifest, ManifestSchemaVersion, PluginMetadata,
2085 PythonRequirement, TriggerType,
2086 };
2087
2088 fn sample_manifest() -> Manifest {
2089 Manifest {
2090 manifest_schema_version: ManifestSchemaVersion::new(1, 1),
2091 plugin: PluginMetadata {
2092 name: "downsampler".parse().unwrap(),
2093 version: semver::Version::new(1, 2, 0),
2094 description: Description::try_new("A downsampling plugin").unwrap(),
2095 triggers: vec![
2096 TriggerType::ProcessWrites,
2097 TriggerType::ProcessScheduledCall,
2098 ],
2099 homepage: Some(url::Url::parse("https://example.com").unwrap()),
2100 repository: Some(url::Url::parse("https://github.com/example/repo").unwrap()),
2101 documentation: Some(url::Url::parse("https://docs.example.com").unwrap()),
2102 exclude: vec![],
2103 },
2104 dependencies: Dependencies {
2105 database_version: ">=3.2.0,<4.0.0".parse().unwrap(),
2106 python: vec![PythonRequirement::try_new("requests>=2.31,<3").unwrap()],
2107 plugins: vec![crate::PluginDependency {
2108 index_url: crate::IndexUrl::try_new("https://plugins.example.com/index.json")
2109 .unwrap(),
2110 name: "geo-lookup".parse().unwrap(),
2111 version: ">=1.0.0,<2.0.0".parse().unwrap(),
2112 }],
2113 },
2114 }
2115 }
2116
2117 fn sample_hash() -> ArtifactHash {
2118 ArtifactHash::try_new(
2119 "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
2120 )
2121 .unwrap()
2122 }
2123
2124 #[test]
2125 fn copies_name() {
2126 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2127 assert_eq!(entry.name.as_str(), "downsampler");
2128 }
2129
2130 #[test]
2131 fn copies_version() {
2132 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2133 assert_eq!(entry.version, semver::Version::new(1, 2, 0));
2134 }
2135
2136 #[test]
2137 fn copies_description() {
2138 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2139 assert_eq!(entry.description.as_str(), "A downsampling plugin");
2140 }
2141
2142 #[test]
2143 fn copies_triggers() {
2144 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2145 assert_eq!(
2146 entry.triggers,
2147 vec![
2148 TriggerType::ProcessWrites,
2149 TriggerType::ProcessScheduledCall
2150 ]
2151 );
2152 }
2153
2154 #[test]
2155 fn copies_homepage() {
2156 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2157 assert_eq!(entry.homepage.unwrap().as_str(), "https://example.com/");
2158 }
2159
2160 #[test]
2161 fn copies_repository() {
2162 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2163 assert_eq!(
2164 entry.repository.unwrap().as_str(),
2165 "https://github.com/example/repo"
2166 );
2167 }
2168
2169 #[test]
2170 fn copies_documentation() {
2171 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2172 assert_eq!(
2173 entry.documentation.unwrap().as_str(),
2174 "https://docs.example.com/"
2175 );
2176 }
2177
2178 #[test]
2179 fn copies_dependencies() {
2180 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2181 assert_eq!(entry.dependencies.python.len(), 1);
2182 assert_eq!(entry.dependencies.plugins.len(), 1);
2183 let dep = &entry.dependencies.plugins[0];
2184 assert_eq!(
2185 dep.index_url.as_url().as_str(),
2186 "https://plugins.example.com/index.json"
2187 );
2188 assert_eq!(dep.name.as_str(), "geo-lookup");
2189 }
2190
2191 #[test]
2192 fn copies_hash() {
2193 let h = sample_hash();
2194 let entry = IndexEntry::from_manifest(sample_manifest(), h.clone());
2195 assert_eq!(entry.hash, h);
2196 }
2197
2198 #[test]
2199 fn copies_injected_published_at() {
2200 let published_at = PublishedAt::try_new("2027-01-02T03:04:05Z").unwrap();
2201 let entry = IndexEntry::from_manifest_with_published_at(
2202 sample_manifest(),
2203 sample_hash(),
2204 published_at.clone(),
2205 );
2206 assert_eq!(entry.published_at, published_at);
2207 }
2208
2209 #[test]
2210 fn from_manifest_assigns_valid_cargo_pubtime_published_at() {
2211 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2212 assert_eq!(
2213 PublishedAt::try_new(entry.published_at.as_str()).unwrap(),
2214 entry.published_at
2215 );
2216 assert_eq!(entry.published_at.as_str().len(), PublishedAt::LEN);
2217 assert!(entry.published_at.as_str().ends_with('Z'));
2218 assert!(!entry.published_at.as_str().contains('.'));
2219 assert!(!entry.published_at.as_str().contains('+'));
2220 }
2221
2222 #[test]
2223 fn yanked_is_false() {
2224 let entry = IndexEntry::from_manifest(sample_manifest(), sample_hash());
2225 assert!(!entry.yanked);
2226 }
2227}
2228
2229#[cfg(test)]
2230mod plugin_dependency_index_tests {
2231 use super::*;
2232 use assert_matches::assert_matches;
2233
2234 fn index_with_entry_deps(deps_json: &str) -> String {
2235 format!(
2236 r#"{{
2237 "index_schema_version": "2.1",
2238 "artifacts_url": "https://example.com/artifacts",
2239 "plugins": [
2240 {{
2241 "name": "downsampler",
2242 "version": "1.2.0",
2243 "published_at": "2026-04-29T18:45:12Z",
2244 "description": "desc",
2245 "triggers": ["process_writes"],
2246 "dependencies": {{ "database_version": ">=3.0.0", "python": []{deps_json} }},
2247 "hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2248 }}
2249 ]
2250}}"#
2251 )
2252 }
2253
2254 #[test]
2255 fn parses_entry_plugin_dependencies() {
2256 let src = index_with_entry_deps(
2257 r#", "plugins": [
2258 {"index_url": "https://plugins.example.com/index.json",
2259 "name": "geo-lookup",
2260 "version": ">=1.0.0,<2.0.0"}
2261 ]"#,
2262 );
2263 let idx = Index::parse_json(&src).expect("entry plugin deps should parse");
2264 let deps = &idx.plugins[0].dependencies.plugins;
2265 assert_eq!(deps.len(), 1);
2266 assert_eq!(
2267 deps[0].index_url.as_url().as_str(),
2268 "https://plugins.example.com/index.json"
2269 );
2270 assert_eq!(deps[0].name.as_str(), "geo-lookup");
2271 }
2272
2273 #[test]
2274 fn missing_entry_plugins_defaults_empty() {
2275 let src = index_with_entry_deps("");
2276 let idx = Index::parse_json(&src).unwrap();
2277 assert!(idx.plugins[0].dependencies.plugins.is_empty());
2278 }
2279
2280 #[test]
2282 fn rejects_invalid_entry_fields_with_index_paths() {
2283 let src = index_with_entry_deps(
2284 r#", "plugins": [
2285 {"index_url": "https://plugins.example.com/index.json",
2286 "name": "geo-lookup",
2287 "version": ">=1.0.0"},
2288 {"index_url": "s3://bucket/index.json",
2289 "name": "Bad Name",
2290 "version": ">=bad"}
2291 ]"#,
2292 );
2293 let errors = Index::parse_json(&src).expect_err("should reject");
2294 let paths: Vec<&str> = errors.errors().iter().map(|r| r.path.as_str()).collect();
2295 assert_eq!(
2296 paths,
2297 vec![
2298 "plugins[0].dependencies.plugins[1].index_url",
2299 "plugins[0].dependencies.plugins[1].name",
2300 "plugins[0].dependencies.plugins[1].version",
2301 ]
2302 );
2303 }
2304
2305 #[test]
2306 fn rejects_duplicate_entry_dependency() {
2307 let src = index_with_entry_deps(
2308 r#", "plugins": [
2309 {"index_url": "https://plugins.example.com/index.json",
2310 "name": "geo-lookup",
2311 "version": ">=1.0.0"},
2312 {"index_url": "https://plugins.EXAMPLE.com/index.json",
2313 "name": "geo_lookup",
2314 "version": ">=2.0.0"}
2315 ]"#,
2316 );
2317 let errors = Index::parse_json(&src).expect_err("duplicate should reject");
2318 assert_eq!(errors.errors().len(), 1, "errors: {errors}");
2319 assert_eq!(
2320 errors.errors()[0].path.as_str(),
2321 "plugins[0].dependencies.plugins[1]"
2322 );
2323 assert_matches!(
2324 errors.errors()[0].error,
2325 SchemaError::DuplicatePluginDependency { .. }
2326 );
2327 }
2328}