1use crate::types::{DartVersion, PackageInfo};
4use crate::version::compare_versions;
5use deps_core::{DepsError, HttpCache, Result, is_dot_segment, lsp_helpers::warn_rejected_value};
6use serde::Deserialize;
7use std::any::Any;
8use std::sync::Arc;
9
10const PUB_DEV_API_BASE: &str = "https://pub.dev/api";
11
12pub const REGISTRY: &str = "pub.dev";
14
15pub fn package_url(name: &str) -> String {
21 format!("https://pub.dev/packages/{}", urlencoding::encode(name))
22}
23
24fn package_metadata_url(base: &str, name: &str) -> String {
34 format!("{base}/packages/{}", urlencoding::encode(name))
35}
36
37fn reject_dot_segment(name: &str) -> Result<()> {
43 if is_dot_segment(name) {
44 warn_rejected_value(
45 "is_dot_segment",
46 "pub.dev package metadata request URL",
47 name,
48 );
49 return Err(DepsError::PackageNotFound {
50 package: name.to_string(),
51 registry: REGISTRY,
52 });
53 }
54 Ok(())
55}
56
57#[derive(Clone)]
58pub struct PubDevRegistry {
59 cache: Arc<HttpCache>,
60 base: String,
64}
65
66impl PubDevRegistry {
67 pub fn new(cache: Arc<HttpCache>) -> Self {
68 Self {
69 cache,
70 base: PUB_DEV_API_BASE.to_string(),
71 }
72 }
73
74 #[cfg(test)]
77 fn with_base(cache: Arc<HttpCache>, base: String) -> Self {
78 Self { cache, base }
79 }
80
81 pub async fn get_versions(&self, name: &str) -> Result<Vec<DartVersion>> {
82 reject_dot_segment(name)?;
83 let url = package_metadata_url(&self.base, name);
84 let data = self.cache.get_cached(&url).await?;
85 parse_versions_response(&data)
86 }
87
88 pub async fn get_latest_matching(
89 &self,
90 name: &str,
91 req_str: &str,
92 ) -> Result<Option<DartVersion>> {
93 let versions = self.get_versions(name).await?;
94 Ok(versions.into_iter().find(|v| {
95 crate::version::version_matches_constraint(v.version.as_str(), req_str) && !v.retracted
96 }))
97 }
98
99 pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<PackageInfo>> {
100 let url = format!("{}/search?q={}", self.base, urlencoding::encode(query));
101 let data = self.cache.get_cached(&url).await?;
102 let search_result: SearchResponse = deps_core::parse_json_checked(&data)?;
103
104 let mut results = Vec::new();
105 for entry in search_result.packages.into_iter().take(limit) {
106 if reject_dot_segment(&entry.package).is_err() {
111 continue;
112 }
113 let pkg_url = package_metadata_url(&self.base, &entry.package);
114 if let Ok(pkg_data) = self.cache.get_cached(&pkg_url).await
115 && let Ok(info) = parse_package_info(&pkg_data)
116 {
117 results.push(info);
118 }
119 }
120
121 Ok(results)
122 }
123
124 pub async fn get_package_info(&self, name: &str) -> Result<PackageInfo> {
125 reject_dot_segment(name)?;
126 let url = package_metadata_url(&self.base, name);
127 let data = self.cache.get_cached(&url).await?;
128 parse_package_info(&data)
129 }
130}
131
132#[derive(Deserialize)]
133struct PackageResponse {
134 name: String,
135 latest: VersionDetail,
136 versions: Vec<VersionEntry>,
137}
138
139#[derive(Deserialize)]
140struct VersionEntry {
141 version: String,
142 #[serde(default)]
143 retracted: bool,
144 published: Option<String>,
145}
146
147#[derive(Deserialize)]
148struct VersionDetail {
149 version: String,
150 pubspec: Option<PubspecMeta>,
151}
152
153#[derive(Deserialize)]
154struct PubspecMeta {
155 name: Option<String>,
156 description: Option<String>,
157 homepage: Option<String>,
158 repository: Option<String>,
159 documentation: Option<String>,
160}
161
162#[derive(Deserialize)]
163struct SearchResponse {
164 #[serde(default)]
165 packages: Vec<SearchEntry>,
166}
167
168#[derive(Deserialize)]
169struct SearchEntry {
170 package: String,
171}
172
173fn parse_versions_response(data: &[u8]) -> Result<Vec<DartVersion>> {
174 let response: PackageResponse = deps_core::parse_json_checked(data)?;
175
176 let mut versions: Vec<DartVersion> = response
177 .versions
178 .into_iter()
179 .map(|e| DartVersion {
180 version: e.version.into(),
181 retracted: e.retracted,
182 published_at: e
183 .published
184 .as_deref()
185 .and_then(deps_core::PublishTime::parse_rfc3339),
186 })
187 .collect();
188
189 versions.sort_by(|a, b| compare_versions(b.version.as_str(), a.version.as_str()));
190
191 Ok(versions)
192}
193
194fn parse_package_info(data: &[u8]) -> Result<PackageInfo> {
195 let response: PackageResponse = deps_core::parse_json_checked(data)?;
196 let pubspec = response.latest.pubspec.unwrap_or(PubspecMeta {
197 name: Some(response.name.clone()),
198 description: None,
199 homepage: None,
200 repository: None,
201 documentation: None,
202 });
203
204 Ok(PackageInfo {
205 name: pubspec.name.unwrap_or(response.name).into(),
206 description: pubspec.description,
207 homepage: pubspec.homepage,
208 repository: pubspec.repository,
209 documentation: pubspec.documentation,
210 version: response.latest.version.into(),
211 license: None,
212 })
213}
214
215impl deps_core::Version for DartVersion {
216 fn version_string(&self) -> &deps_core::ConcreteVersion {
217 &self.version
218 }
219
220 fn removal_status(&self) -> deps_core::RemovalStatus {
221 deps_core::RemovalStatus::from_yanked(self.retracted)
222 }
223
224 fn is_prerelease(&self) -> bool {
225 crate::version::is_prerelease(self.version.as_str())
226 }
227
228 fn published_at(&self) -> Option<deps_core::PublishTime> {
229 self.published_at
230 }
231
232 fn as_any(&self) -> &dyn std::any::Any {
233 self
234 }
235}
236
237impl deps_core::Metadata for PackageInfo {
238 fn name(&self) -> &deps_core::PackageName {
239 &self.name
240 }
241
242 fn description(&self) -> Option<&str> {
243 self.description.as_deref()
244 }
245
246 fn repository(&self) -> Option<&str> {
247 self.repository.as_deref()
248 }
249
250 fn documentation(&self) -> Option<&str> {
251 self.documentation.as_deref()
252 }
253
254 fn latest_version(&self) -> &deps_core::ConcreteVersion {
255 &self.version
256 }
257
258 fn as_any(&self) -> &dyn std::any::Any {
259 self
260 }
261}
262
263impl deps_core::Registry for PubDevRegistry {
265 fn get_versions<'a>(
266 &'a self,
267 name: &'a deps_core::PackageName,
268 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
269 Box::pin(async move {
270 let versions = self.get_versions(name.as_str()).await?;
271 Ok(versions
272 .into_iter()
273 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
274 .collect())
275 })
276 }
277
278 fn get_latest_matching<'a>(
279 &'a self,
280 name: &'a deps_core::PackageName,
281 req: &'a deps_core::VersionReq,
282 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
283 Box::pin(async move {
284 let version = self
285 .get_latest_matching(name.as_str(), req.as_str())
286 .await?;
287 Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
288 })
289 }
290
291 fn search<'a>(
292 &'a self,
293 query: &'a str,
294 limit: usize,
295 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
296 Box::pin(async move {
297 let results = self.search(query, limit).await?;
298 Ok(results
299 .into_iter()
300 .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
301 .collect())
302 })
303 }
304
305 fn select_latest_matching(
306 &self,
307 versions: &[Box<dyn deps_core::Version>],
308 req: &deps_core::VersionReq,
309 ) -> Option<usize> {
310 if deps_core::is_existence_wildcard(req) {
311 return deps_core::select_latest_for_existence(versions, |v| v.as_ref());
312 }
313 versions.iter().position(|v| {
314 crate::version::version_matches_constraint(v.version_string().as_str(), req.as_str())
315 && !v.removal_status().blocks_resolution()
316 })
317 }
318
319 fn as_any(&self) -> &dyn Any {
320 self
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 use std::assert_matches;
329
330 #[test]
331 fn test_package_url() {
332 assert_eq!(package_url("provider"), "https://pub.dev/packages/provider");
333 assert_eq!(package_url("http"), "https://pub.dev/packages/http");
334 }
335
336 #[test]
337 fn test_package_url_encodes_malicious_name() {
338 let url = package_url("evil](https://evil.example)[pkg");
339 assert!(!url.contains('('));
340 assert!(!url.contains(')'));
341 assert!(!url.contains('['));
342 assert!(!url.contains(']'));
343 }
344
345 #[test]
346 fn test_package_url_encodes_newline_autolink_and_percent() {
347 let url = package_url("evil\n<https://evil%zz.example>");
348 assert!(!url.contains('\n'));
349 assert!(!url.contains('<'));
350 assert!(!url.contains('>'));
351 assert!(url.contains("%25"));
352 }
353
354 #[test]
355 fn test_package_url_empty_name() {
356 assert_eq!(package_url(""), "https://pub.dev/packages/");
357 }
358
359 #[test]
360 fn test_parse_versions_response() {
361 let json = r#"{
362 "name": "http",
363 "latest": {"version": "1.2.0", "pubspec": {"name": "http"}},
364 "versions": [
365 {"version": "1.0.0", "retracted": false},
366 {"version": "1.2.0", "retracted": false},
367 {"version": "1.1.0", "retracted": false},
368 {"version": "0.9.0", "retracted": true}
369 ]
370 }"#;
371
372 let versions = parse_versions_response(json.as_bytes()).unwrap();
373 assert_eq!(versions.len(), 4);
374 assert_eq!(versions[0].version, "1.2.0");
375 assert_eq!(versions[1].version, "1.1.0");
376 assert_eq!(versions[2].version, "1.0.0");
377 assert!(versions[3].retracted);
378 }
379
380 #[test]
381 fn test_parse_versions_response_with_published() {
382 let json = r#"{
383 "name": "http",
384 "latest": {"version": "1.2.0"},
385 "versions": [
386 {"version": "1.2.0", "retracted": false, "published": "2025-03-10T14:22:05.123Z"}
387 ]
388 }"#;
389
390 let versions = parse_versions_response(json.as_bytes()).unwrap();
391 assert_eq!(versions.len(), 1);
392 assert_eq!(
393 versions[0].published_at,
394 deps_core::PublishTime::parse_rfc3339("2025-03-10T14:22:05.123Z")
395 );
396 }
397
398 #[test]
399 fn test_parse_versions_response_without_published() {
400 let json = r#"{
401 "name": "http",
402 "latest": {"version": "1.2.0"},
403 "versions": [
404 {"version": "1.2.0", "retracted": false}
405 ]
406 }"#;
407
408 let versions = parse_versions_response(json.as_bytes()).unwrap();
409 assert_eq!(versions.len(), 1);
410 assert!(versions[0].published_at.is_none());
411 }
412
413 #[test]
414 fn test_parse_versions_response_with_malformed_published() {
415 let json = r#"{
416 "name": "http",
417 "latest": {"version": "1.2.0"},
418 "versions": [
419 {"version": "1.2.0", "retracted": false, "published": "not-a-timestamp"}
420 ]
421 }"#;
422
423 let versions = parse_versions_response(json.as_bytes()).unwrap();
424 assert_eq!(versions.len(), 1);
425 assert!(
426 versions[0].published_at.is_none(),
427 "malformed published degrades to None, not an error"
428 );
429 }
430
431 #[test]
432 fn test_parse_versions_response_empty() {
433 let json = r#"{
434 "name": "test",
435 "latest": {"version": "1.0.0"},
436 "versions": []
437 }"#;
438 let versions = parse_versions_response(json.as_bytes()).unwrap();
439 assert!(versions.is_empty());
440 }
441
442 #[test]
443 fn test_parse_package_info() {
444 let json = r#"{
445 "name": "provider",
446 "latest": {
447 "version": "6.1.2",
448 "pubspec": {
449 "name": "provider",
450 "description": "A wrapper around InheritedWidget",
451 "homepage": "https://pub.dev/packages/provider",
452 "repository": "https://github.com/rrousselGit/provider",
453 "documentation": "https://pub.dev/documentation/provider"
454 }
455 },
456 "versions": []
457 }"#;
458
459 let info = parse_package_info(json.as_bytes()).unwrap();
460 assert_eq!(info.name, "provider");
461 assert_eq!(
462 info.description,
463 Some("A wrapper around InheritedWidget".into())
464 );
465 assert_eq!(info.version, "6.1.2");
466 }
467
468 #[test]
469 fn test_parse_package_info_minimal() {
470 let json = r#"{
471 "name": "minimal",
472 "latest": {"version": "0.1.0"},
473 "versions": []
474 }"#;
475
476 let info = parse_package_info(json.as_bytes()).unwrap();
477 assert_eq!(info.name, "minimal");
478 assert_eq!(info.version, "0.1.0");
479 assert!(info.description.is_none());
480 }
481
482 #[test]
483 fn test_parse_search_response() {
484 let json = r#"{
485 "packages": [
486 {"package": "provider"},
487 {"package": "riverpod"}
488 ]
489 }"#;
490 let response: SearchResponse = serde_json::from_slice(json.as_bytes()).unwrap();
491 assert_eq!(response.packages.len(), 2);
492 assert_eq!(response.packages[0].package, "provider");
493 }
494
495 #[test]
496 fn test_registry_creation() {
497 let cache = Arc::new(HttpCache::new());
498 let _registry = PubDevRegistry::new(cache);
499 }
500
501 #[test]
509 fn test_package_metadata_url_encodes_path_traversal() {
510 let name = "../../search";
511 let url = package_metadata_url(PUB_DEV_API_BASE, name);
512 let parsed = url::Url::parse(&url).unwrap();
513 let segments: Vec<&str> = parsed.path_segments().unwrap().collect();
514 assert_eq!(segments.len(), 3, "segments: {segments:?}");
515 assert_eq!(segments[0], "api");
516 assert_eq!(segments[1], "packages");
517 assert_eq!(urlencoding::decode(segments[2]).unwrap(), name);
518 }
519
520 #[test]
532 fn test_package_metadata_url_dot_dot_normalizes_above_packages_prefix() {
533 let url = package_metadata_url(PUB_DEV_API_BASE, "..");
534 let parsed = url::Url::parse(&url).unwrap();
535 assert_eq!(parsed.path(), "/api/", "parsed path: {}", parsed.path());
536 }
537
538 #[test]
539 fn test_reject_dot_segment_rejects_bare_dot_dot() {
540 assert!(reject_dot_segment("..").is_err());
541 }
542
543 #[test]
544 fn test_reject_dot_segment_rejects_bare_dot() {
545 assert!(reject_dot_segment(".").is_err());
546 }
547
548 #[test]
549 fn test_reject_dot_segment_accepts_normal_names() {
550 assert!(reject_dot_segment("provider").is_ok());
551 assert!(reject_dot_segment("../../search").is_ok());
552 }
553
554 #[test]
558 fn test_package_metadata_url_dot_segment_sweep() {
559 deps_core::test_util::assert_dot_segment_gated_or_contained(
560 |seg| (!is_dot_segment(seg)).then(|| package_metadata_url(PUB_DEV_API_BASE, seg)),
561 "pub.dev",
562 "/api/packages/",
563 );
564 }
565
566 #[tokio::test]
567 async fn test_get_versions_rejects_bare_dot_dot_as_not_found() {
568 let registry = PubDevRegistry::new(Arc::new(HttpCache::new()));
573 let err = registry.get_versions("..").await.unwrap_err();
574 assert_matches!(err, DepsError::PackageNotFound { .. });
575 }
576
577 #[tokio::test]
578 async fn test_get_package_info_rejects_bare_dot_as_not_found() {
579 let registry = PubDevRegistry::new(Arc::new(HttpCache::new()));
580 let err = registry.get_package_info(".").await.unwrap_err();
581 assert_matches!(err, DepsError::PackageNotFound { .. });
582 }
583
584 #[tokio::test]
591 async fn test_search_inner_fetch_encodes_malicious_package_name() {
592 let mut server = mockito::Server::new_async().await;
593 let search_mock = server
594 .mock("GET", "/search?q=widget")
595 .with_status(200)
596 .with_body(r#"{"packages": [{"package": "../../search"}]}"#)
597 .create_async()
598 .await;
599 let pkg_mock = server
603 .mock("GET", "/packages/..%2F..%2Fsearch")
604 .with_status(200)
605 .with_body(r#"{"name": "search", "latest": {"version": "1.0.0"}, "versions": []}"#)
606 .create_async()
607 .await;
608
609 let registry = PubDevRegistry::with_base(Arc::new(HttpCache::new()), server.url());
610 let results = registry.search("widget", 10).await.unwrap();
611
612 assert_eq!(results.len(), 1);
613 search_mock.assert_async().await;
614 pkg_mock.assert_async().await;
615 }
616
617 #[tokio::test]
618 async fn test_search_inner_fetch_skips_dot_segment_package_name() {
619 let mut server = mockito::Server::new_async().await;
620 let search_mock = server
621 .mock("GET", "/search?q=widget")
622 .with_status(200)
623 .with_body(r#"{"packages": [{"package": ".."}, {"package": "provider"}]}"#)
624 .create_async()
625 .await;
626 let pkg_mock = server
627 .mock("GET", "/packages/provider")
628 .with_status(200)
629 .with_body(r#"{"name": "provider", "latest": {"version": "1.0.0"}, "versions": []}"#)
630 .create_async()
631 .await;
632 let registry = PubDevRegistry::with_base(Arc::new(HttpCache::new()), server.url());
636 let results = registry.search("widget", 10).await.unwrap();
637
638 assert_eq!(
639 results.len(),
640 1,
641 "the dot-segment entry must be skipped, not fetched"
642 );
643 search_mock.assert_async().await;
644 pkg_mock.assert_async().await;
645 }
646
647 #[test]
648 fn test_version_trait() {
649 use deps_core::Version;
650 let ver = DartVersion {
651 version: "1.0.0".into(),
652 retracted: true,
653 published_at: None,
654 };
655 assert_eq!(ver.version_string(), "1.0.0");
656 assert!(ver.removal_status().blocks_resolution());
657 assert!(ver.features().is_empty());
658 }
659
660 #[test]
661 fn test_metadata_trait() {
662 use deps_core::Metadata;
663 let info = PackageInfo {
664 name: "test".into(),
665 description: Some("A test package".into()),
666 homepage: None,
667 repository: Some("https://github.com/test/test".into()),
668 documentation: None,
669 version: "1.0.0".into(),
670 license: None,
671 };
672 assert_eq!(info.name(), "test");
673 assert_eq!(info.description(), Some("A test package"));
674 assert_eq!(info.repository(), Some("https://github.com/test/test"));
675 assert!(info.documentation().is_none());
676 }
677
678 #[test]
679 fn test_registry_as_any() {
680 use deps_core::Registry;
681 let cache = Arc::new(HttpCache::new());
682 let registry = PubDevRegistry::new(cache);
683 assert!(registry.as_any().is::<PubDevRegistry>());
684 }
685
686 #[test]
687 fn test_select_latest_matching_not_default_none() {
688 use deps_core::{Registry, VersionReq};
689
690 let cache = Arc::new(HttpCache::new());
691 let registry = PubDevRegistry::new(cache);
692 let versions: Vec<Box<dyn deps_core::Version>> = vec![
693 Box::new(DartVersion {
694 version: "2.0.0".into(),
695 retracted: true,
696 published_at: None,
697 }),
698 Box::new(DartVersion {
699 version: "1.0.0".into(),
700 retracted: false,
701 published_at: None,
702 }),
703 ];
704 let req = VersionReq::new("*");
705 assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
706 }
707
708 #[test]
709 fn test_select_latest_matching_all_retracted_returns_newest_retracted() {
710 use deps_core::{Registry, VersionReq};
711
712 let cache = Arc::new(HttpCache::new());
713 let registry = PubDevRegistry::new(cache);
714 let versions: Vec<Box<dyn deps_core::Version>> = vec![
715 Box::new(DartVersion {
716 version: "2.0.0".into(),
717 retracted: true,
718 published_at: None,
719 }),
720 Box::new(DartVersion {
721 version: "1.0.0".into(),
722 retracted: true,
723 published_at: None,
724 }),
725 ];
726 let req = VersionReq::new("*");
727 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
728 }
729
730 #[test]
731 fn test_select_latest_matching_all_prerelease_returns_newest_prerelease() {
732 use deps_core::{Registry, VersionReq};
733
734 let cache = Arc::new(HttpCache::new());
735 let registry = PubDevRegistry::new(cache);
736 let versions: Vec<Box<dyn deps_core::Version>> = vec![
737 Box::new(DartVersion {
738 version: "2.0.0-beta.1".into(),
739 retracted: false,
740 published_at: None,
741 }),
742 Box::new(DartVersion {
743 version: "1.0.0-alpha.1".into(),
744 retracted: false,
745 published_at: None,
746 }),
747 ];
748 let req = VersionReq::new("*");
749 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
750 }
751}