diff --git a/Cargo.lock b/Cargo.lock index 3c99cd4..ff2f02f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,7 @@ dependencies = [ "indicatif", "pep440_rs", "predicates", + "quick-xml", "regex", "reqwest", "semver", @@ -1233,6 +1234,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.12" diff --git a/Cargo.toml b/Cargo.toml index 6659bcc..2068e2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ pep440_rs = "0.7.3" thiserror = "2.0.20" anyhow = "1.0.104" regex = "1.13.1" +quick-xml = { version = "0.42.0", features = ["serialize"] } async-trait = "0.1.92" indicatif = "0.18.6" colored = "3.1.1" diff --git a/docs/ecosystems.ja.md b/docs/ecosystems.ja.md index 156e112..64d17da 100644 --- a/docs/ecosystems.ja.md +++ b/docs/ecosystems.ja.md @@ -150,6 +150,8 @@ Composer は `~>` 演算子を受け付けない(`Invalid operator "~>"`)た ### Java(Gradle) +Maven Central の依存では、リポジトリのアーティファクト単位の `maven-metadata.xml` から版一覧を取得します。更新候補として選んだ版の POM の `Last-Modified` を確認してから age フィルタを適用します。完全一致の現在版が版一覧に無い場合は「最新」とせず取得失敗を報告します。公開日が無い、または不正な場合も age フィルタを黙って迂回せず取得失敗にします。 + `strictly` / `require` / `prefer` / `reject` を使う Gradle の rich version 宣言は、`implementation("org.slf4j:slf4j-api") { version { ... } }` のような依存ブロック内でも解析します。文字列記法の短縮形でも、完全一致(`group:name:1.2.3!!`)、動的プレフィックス(`group:name:5.3.+!!`)、範囲(`group:name:[1.7, 1.8[!!`)、prefer 付きの strict 範囲(`group:name:[1.7, 1.8[!!1.7.25`)を解析できます。`strictly` または `require` で範囲を、`prefer` で優先するバージョンを指定している場合、depup は範囲を上限制約として維持したまま `prefer` の値を更新します。`reject` に列挙されたバージョンは候補から外し、`2.+` のような動的な reject や `[1.5,1.9)` のような範囲の reject も考慮します。 Gradle の宣言ラッパー `platform(...)` / `enforcedPlatform(...)` / `testFixtures(...)` にも対応しています。`implementation platform('com.google.cloud:libraries-bom:26.1.0')` や `testImplementation(platform("org.junit:junit-bom:5.10.0"))` のような BOM 宣言も解析・更新でき、開発依存かどうかはラッパーの外側にある configuration 名(`implementation` / `testImplementation` など)で判定します。 diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 650cd8a..0bb38c7 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -154,6 +154,8 @@ Composer rejects the `~>` operator (`Invalid operator "~>"`), so for PHP a `~>` ### Java (Gradle) +For Maven Central dependencies, depup reads artifact-level `maven-metadata.xml` from the repository to discover versions. It checks the POM's `Last-Modified` header for each version selected as an update candidate, then applies the age filter. If an exact current version is absent from the repository metadata, depup reports a fetch failure instead of claiming it is already latest. A missing or invalid release date also fails the fetch rather than silently bypassing the age filter. + Gradle rich version declarations using `strictly`, `require`, `prefer`, and `reject` are parsed in dependency blocks such as `implementation("org.slf4j:slf4j-api") { version { ... } }`. String notation shorthand supports exact, dynamic-prefix, and range constraints, including `group:name:1.2.3!!`, `group:name:5.3.+!!`, `group:name:[1.7, 1.8[!!`, and a strict range with a preferred version such as `group:name:[1.7, 1.8[!!1.7.25`. When `strictly` or `require` declares a range and `prefer` declares the selected version, depup keeps the range as the upper-bound constraint and updates the `prefer` value. Versions listed with `reject` are removed from the candidates, including dynamic rejects such as `2.+` and ranges such as `[1.5,1.9)`. Gradle declaration wrappers are supported: `platform(...)`, `enforcedPlatform(...)`, and `testFixtures(...)`. BOM declarations such as `implementation platform('com.google.cloud:libraries-bom:26.1.0')` and `testImplementation(platform("org.junit:junit-bom:5.10.0"))` are parsed and updated, and the surrounding configuration name is still used for the dev/production classification. diff --git a/src/orchestrator.rs b/src/orchestrator.rs index 94da409..adfe3c5 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -285,6 +285,20 @@ impl Orchestrator { // registry 経由のフェッチ match self.fetch_versions(adapter, &dep.name).await { Ok(versions) => { + let versions = match self + .resolve_java_release_dates(&dep, adapter, judge, versions) + .await + { + Ok(versions) => versions, + Err(error) => { + return OnePassResult { + name: dep.name.clone(), + outcome: UpdateResult::skip_fetch_failed(dep, error.clone()), + fetch_error: Some(error), + osv_warnings: Vec::new(), + }; + } + }; // OSV チェック: judge で採用しようとした候補だけを問い合わせる。 // 脆弱なら、その候補を除外して再 judge するループで安全な候補に // 自然にフォールバックする (1 依存あたり通常 1〜2 API call で済む)。 @@ -292,10 +306,36 @@ impl Orchestrator { let mut osv_warnings = Vec::new(); let result = match (self.osv_checker.as_ref(), dep.language.osv_ecosystem()) { (Some(checker), Some(eco)) => { - judge_with_osv(judge, &dep, versions, checker, eco, bar, &mut osv_warnings) - .await + let mut context = OsvJudgeContext { + checker, + ecosystem: eco, + bar, + warnings: &mut osv_warnings, + }; + judge_with_osv(self, adapter, judge, &dep, versions, &mut context).await + } + _ => Ok(judge.judge(&dep, &versions)), + }; + let result = match result { + Ok(result) => result, + Err(error) => { + return OnePassResult { + name: dep.name.clone(), + outcome: UpdateResult::skip_fetch_failed(dep, error.clone()), + fetch_error: Some(error), + osv_warnings, + }; + } + }; + let result = match result { + UpdateResult::Skip { + reason: SkipReason::AlreadyLatest, + released_at: Some(date), + .. + } if adapter.release_dates_deferred() && date.timestamp() == 0 => { + UpdateResult::skip_already_latest(dep.clone()) } - _ => judge.judge(&dep, &versions), + other => other, }; OnePassResult { name: dep.name.clone(), @@ -842,6 +882,100 @@ impl Orchestrator { self.versions.fetch(adapter, package).await } + /// Maven metadata has no per-version date. Resolve only versions the judge would + /// actually select, then re-run the age filter with the POM's Last-Modified date. + async fn resolve_java_release_dates( + &self, + dep: &Dependency, + adapter: &(dyn RegistryAdapter + Send + Sync), + judge: &UpdateJudge, + mut versions: Vec, + ) -> Result, String> { + if !adapter.release_dates_deferred() || versions.is_empty() { + return Ok(versions); + } + if dep.version_spec.kind == crate::domain::VersionSpecKind::Exact + && !versions.iter().any(|candidate| { + compare_dependency_versions(dep, &candidate.version, dep.version()) + == std::cmp::Ordering::Equal + }) + { + return Err(format!( + "current version {} is absent from Maven metadata", + dep.version() + )); + } + + // Each pass resolves one new candidate. A candidate too young for min_age is + // excluded by the next pass; older candidates remain available as fallbacks. + let mut confirmed_limited = false; + for _ in 0..=versions.len() { + let (selected, checking_limit) = match judge.judge(dep, &versions) { + UpdateResult::Update { new_version, .. } => (Some(new_version), false), + UpdateResult::Skip { + reason: SkipReason::AlreadyLatest, + .. + } if self.args.verbose + && dep.version_spec.kind == crate::domain::VersionSpecKind::Exact => + { + ( + versions + .iter() + .find(|candidate| { + compare_dependency_versions(dep, &candidate.version, dep.version()) + == std::cmp::Ordering::Equal + }) + .map(|candidate| candidate.version.clone()), + false, + ) + } + UpdateResult::Skip { + reason: SkipReason::ChangeLevelLimited(max), + .. + } if !confirmed_limited => { + let candidate = judge + .candidates_before_age(dep, &versions) + .into_iter() + .filter(|info| { + info.released_at.timestamp() == 0 + && compare_dependency_versions(dep, &info.version, dep.version()) + == std::cmp::Ordering::Greater + && crate::domain::ChangeLevel::from_versions( + dep.version(), + &info.version, + ) + .is_some_and(|level| level > max) + }) + .max_by(|a, b| compare_dependency_versions(dep, &a.version, &b.version)) + .map(|info| info.version.clone()); + (candidate, true) + } + _ => (None, false), + }; + let Some(selected) = selected else { + return Ok(versions); + }; + let Some(info) = versions.iter_mut().find(|info| info.version == selected) else { + return Err(format!( + "selected Maven version {selected} is absent from metadata" + )); + }; + if info.released_at.timestamp() != 0 { + return Ok(versions); + } + let date = self + .versions + .release_date(adapter, &dep.name, &selected) + .await? + .ok_or_else(|| format!("release date unavailable for {selected}"))?; + info.released_at = date; + if checking_limit && judge.admits_age(dep, info) { + confirmed_limited = true; + } + } + Err("could not resolve Maven release dates".to_string()) + } + /// Tauriパッケージバージョンを同期する (@tauri-apps/api, @tauri-apps/cli, tauri crate) /// /// Tauriビルドエラーを防ぐため、全パッケージのメジャー.マイナーバージョンを @@ -1118,18 +1252,35 @@ struct OnePassResult { /// 通常 1 依存あたり 1〜2 API call で済む。 /// 全 candidate を網羅的にチェックする旧実装と違い、`@angular/*` のように /// 1000+ バージョンを持つパッケージでも実用的な速度で完了する。 +struct OsvJudgeContext<'a> { + checker: &'a OsvChecker, + ecosystem: &'a str, + bar: Option<&'a ProgressBar>, + warnings: &'a mut Vec, +} + async fn judge_with_osv( + orchestrator: &Orchestrator, + adapter: &(dyn RegistryAdapter + Send + Sync), judge: &UpdateJudge, dep: &Dependency, versions: Vec, - checker: &OsvChecker, - ecosystem: &str, - bar: Option<&ProgressBar>, - warnings: &mut Vec, -) -> UpdateResult { + context: &mut OsvJudgeContext<'_>, +) -> Result { + let bar = context.bar; + let warnings = &mut context.warnings; + let checker = context.checker; + let ecosystem = context.ecosystem; let mut allowed = versions; let mut fallback_chain: Vec = Vec::new(); loop { + allowed = match orchestrator + .resolve_java_release_dates(dep, adapter, judge, allowed) + .await + { + Ok(versions) => versions, + Err(error) => return Err(error), + }; let result = judge.judge(dep, &allowed); let UpdateResult::Update { new_version: target, @@ -1137,7 +1288,7 @@ async fn judge_with_osv( } = &result else { // Skip 結果は OSV と無関係に確定 - return result; + return Ok(result); }; let target = target.clone(); @@ -1155,12 +1306,12 @@ async fn judge_with_osv( target ); osv_println(bar, &line); - return result + return Ok(result .with_osv_skipped(fallback_chain) - .with_osv_checked(true); + .with_osv_checked(true)); } // チェック完了・脆弱性なし - return result.with_osv_checked(true); + return Ok(result.with_osv_checked(true)); } Ok(OsvCheck::Vulnerable(ids)) => { let detail = if ids.is_empty() { @@ -1192,7 +1343,7 @@ async fn judge_with_osv( "could not exclude {} from candidates, stopping OSV check", target )); - return result.with_osv_skipped(fallback_chain); + return Ok(result.with_osv_skipped(fallback_chain)); } // ループ継続 → 次の候補で再判定 } @@ -1200,11 +1351,11 @@ async fn judge_with_osv( let line = format!(" ⚠ OSV check failed for {} {}: {}", dep.name, target, e); osv_println(bar, &line); warnings.push(format!("OSV check failed for {}: {}", target, e)); - return if fallback_chain.is_empty() { + return Ok(if fallback_chain.is_empty() { result } else { result.with_osv_skipped(fallback_chain) - }; + }); } } } @@ -1930,6 +2081,147 @@ mod tests { } } + struct DatedMavenAdapter { + calls: AtomicUsize, + now: chrono::DateTime, + } + + #[async_trait] + impl RegistryAdapter for DatedMavenAdapter { + fn language(&self) -> Language { + Language::Java + } + + fn registry_name(&self) -> &'static str { + "Maven Central" + } + + fn release_dates_deferred(&self) -> bool { + true + } + + async fn fetch_versions( + &self, + _package: &str, + ) -> Result, crate::error::RegistryError> { + Ok(Vec::new()) + } + + async fn fetch_release_date( + &self, + _package: &str, + version: &str, + ) -> Result>, crate::error::RegistryError> { + self.calls.fetch_add(1, Ordering::SeqCst); + let days = if matches!(version, "3.0.0" | "4.0.0") { + 1 + } else { + 30 + }; + Ok(Some(self.now - chrono::Duration::days(days))) + } + } + + #[tokio::test] + async fn maven_dates_are_fetched_only_for_selected_candidates() { + let dir = TempDir::new().unwrap(); + let orchestrator = Orchestrator::new(make_args_with_path(dir.path(), &["--java"])).unwrap(); + let now = chrono::Utc::now(); + let adapter = DatedMavenAdapter { + calls: AtomicUsize::new(0), + now, + }; + let judge = UpdateJudge::with_time( + UpdateFilter::new() + .with_include_pinned(true) + .with_min_age(Duration::from_secs(14 * 86400)), + now, + ); + let dep = Dependency::new( + "example:library", + crate::domain::VersionSpec::new( + crate::domain::VersionSpecKind::Exact, + "1.0.0", + "1.0.0", + ), + false, + Language::Java, + ); + let unknown = chrono::DateTime::from_timestamp(0, 0).unwrap(); + let versions = ["1.0.0", "2.0.0", "3.0.0"] + .map(|version| VersionInfo::new(version, unknown)) + .to_vec(); + let resolved = orchestrator + .resolve_java_release_dates(&dep, &adapter, &judge, versions.clone()) + .await + .unwrap(); + assert_eq!(adapter.calls.load(Ordering::SeqCst), 2); + assert_eq!(resolved[1].released_at, now - chrono::Duration::days(30)); + assert_eq!(resolved[2].released_at, now - chrono::Duration::days(1)); + assert!(matches!( + judge.judge(&dep, &resolved), + UpdateResult::Update { new_version, .. } if new_version == "2.0.0" + )); + orchestrator + .resolve_java_release_dates(&dep, &adapter, &judge, versions) + .await + .unwrap(); + assert_eq!(adapter.calls.load(Ordering::SeqCst), 2); + + let missing = vec![VersionInfo::new("2.0.0", unknown)]; + assert!( + orchestrator + .resolve_java_release_dates(&dep, &adapter, &judge, missing) + .await + .unwrap_err() + .contains("absent from Maven metadata") + ); + } + + #[tokio::test] + async fn young_maven_major_does_not_trigger_max_change_warning() { + let dir = TempDir::new().unwrap(); + let orchestrator = Orchestrator::new(make_args_with_path(dir.path(), &["--java"])).unwrap(); + let now = chrono::Utc::now(); + let adapter = DatedMavenAdapter { + calls: AtomicUsize::new(0), + now, + }; + let judge = UpdateJudge::with_time( + UpdateFilter::new() + .with_include_pinned(true) + .with_min_age(Duration::from_secs(14 * 86400)) + .with_max_change(crate::domain::ChangeLevel::Minor), + now, + ); + let dep = Dependency::new( + "example:library", + crate::domain::VersionSpec::new( + crate::domain::VersionSpecKind::Exact, + "3.0.0", + "3.0.0", + ), + false, + Language::Java, + ); + let unknown = chrono::DateTime::from_timestamp(0, 0).unwrap(); + let versions = ["3.0.0", "4.0.0"] + .map(|version| VersionInfo::new(version, unknown)) + .to_vec(); + let resolved = orchestrator + .resolve_java_release_dates(&dep, &adapter, &judge, versions) + .await + .unwrap(); + assert_eq!(adapter.calls.load(Ordering::SeqCst), 1); + assert!(matches!( + judge.judge(&dep, &resolved), + UpdateResult::Skip { + reason: SkipReason::AlreadyLatest, + .. + } + )); + } + fn make_args(args: &[&str]) -> CliArgs { CliArgs::parse_from(args) } diff --git a/src/registry/maven_central.rs b/src/registry/maven_central.rs index 3eabad2..0654955 100644 --- a/src/registry/maven_central.rs +++ b/src/registry/maven_central.rs @@ -1,9 +1,7 @@ -//! Maven Central Search API アダプタ +//! Maven Central repository metadata adapter. //! -//! Maven Central から Java パッケージのバージョン情報を取得する。 -//! API エンドポイント: https://search.maven.org/solrsearch/select -//! -//! クエリ形式: q=g:{groupId}+AND+a:{artifactId}&core=gav&rows=100&wt=json +//! The search index can lag behind the repository. Artifact-level Maven metadata is the +//! authoritative version list; individual POM responses provide release dates. use crate::domain::Language; use crate::error::RegistryError; @@ -11,85 +9,86 @@ use crate::registry::{HttpClient, RegistryAdapter, is_valid_registry_id_segment} use crate::update::VersionInfo; use async_trait::async_trait; use chrono::{DateTime, TimeZone, Utc}; +use reqwest::header::LAST_MODIFIED; use serde::Deserialize; -/// Maven Central Search API のベース URL -const MAVEN_CENTRAL_API_URL: &str = "https://search.maven.org/solrsearch/select"; - -/// 1 ページあたりの取得件数 -const PAGE_ROWS: u32 = 200; +const MAVEN_CENTRAL_URL: &str = "https://repo1.maven.org/maven2"; -/// 安全弁: 取得する最大ページ数 (= 最大 2000 版)。 -/// GitHub Tags が Link ヘッダを最大 10 ページ辿るのと同じ上限に揃える -const MAX_PAGES: u32 = 10; - -/// Maven Central アダプタ pub struct MavenCentralAdapter { client: HttpClient, + base_url: String, } -/// Maven Central 検索レスポンス #[derive(Debug, Deserialize)] -struct MavenSearchResponse { - response: MavenResponseBody, +struct MavenMetadata { + #[serde(rename = "groupId")] + group_id: String, + #[serde(rename = "artifactId")] + artifact_id: String, + versioning: MavenVersioning, } -/// Maven Central レスポンスボディ #[derive(Debug, Deserialize)] -struct MavenResponseBody { - docs: Vec, - /// 全体のヒット件数 (1 ページ分の `docs` より多いことがある) - #[serde(rename = "numFound", default)] - num_found: u64, +struct MavenVersioning { + versions: MavenVersions, } -/// Maven Central バージョンドキュメント #[derive(Debug, Deserialize)] -struct MavenVersionDoc { - /// バージョン文字列 - v: String, - /// エポックからのミリ秒タイムスタンプ - timestamp: i64, +struct MavenVersions { + #[serde(rename = "version", default)] + version: Vec, } impl MavenCentralAdapter { - /// 新しい Maven Central アダプタを作成 pub fn new(client: HttpClient) -> Self { - Self { client } - } - - /// group:artifact 形式の検索 URL を構築 (`start` はページング用のオフセット) - fn build_url(&self, package: &str, start: u32) -> Result { - // パッケージ形式: "group:artifact" (例: "org.apache.wicket:wicket-core") - let parts: Vec<&str> = package.split(':').collect(); - if parts.len() != 2 { - return Err(RegistryError::InvalidPackageName { - name: package.to_string(), - registry: self.registry_name().to_string(), - reason: "expected format 'groupId:artifactId'".to_string(), - }); + Self { + client, + base_url: MAVEN_CENTRAL_URL.to_string(), } - let (group, artifact) = (parts[0], parts[1]); + } - // Maven coordinates に不正な文字が含まれていないか検証する - // (URLクエリ文字列へのインジェクション防止。GitHub Tags と共通の検証) - if !is_valid_registry_id_segment(group) || !is_valid_registry_id_segment(artifact) { - return Err(RegistryError::InvalidPackageName { - name: package.to_string(), - registry: self.registry_name().to_string(), - reason: "groupId and artifactId must contain only alphanumeric characters, dots, hyphens, and underscores".to_string(), - }); + fn artifact_url(&self, package: &str) -> Result { + let Some((group, artifact)) = package.split_once(':') else { + return Err(self.invalid_package(package)); + }; + if artifact.contains(':') + || !is_valid_registry_id_segment(group) + || !is_valid_registry_id_segment(artifact) + { + return Err(self.invalid_package(package)); } - Ok(format!( - "{}?q=g:{}+AND+a:{}&core=gav&rows={}&start={}&wt=json", - MAVEN_CENTRAL_API_URL, group, artifact, PAGE_ROWS, start + "{}/{}/{}", + self.base_url, + group.replace('.', "/"), + artifact )) } - /// ミリ秒タイムスタンプを DateTime に変換 - fn timestamp_to_datetime(timestamp_ms: i64) -> Option> { - Utc.timestamp_millis_opt(timestamp_ms).single() + fn invalid_package(&self, package: &str) -> RegistryError { + RegistryError::InvalidPackageName { + name: package.to_string(), + registry: self.registry_name().to_string(), + reason: "expected a safe groupId:artifactId coordinate".to_string(), + } + } + + fn version_url(&self, package: &str, version: &str) -> Result { + let artifact_url = self.artifact_url(package)?; + if version.is_empty() + || matches!(version, "." | "..") + || !version + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'+')) + { + return Err(RegistryError::InvalidResponse { + package: package.to_string(), + registry: self.registry_name().to_string(), + message: format!("unsafe Maven version: {version}"), + }); + } + let artifact = package.split_once(':').expect("validated coordinate").1; + Ok(format!("{artifact_url}/{version}/{artifact}-{version}.pom")) } } @@ -103,144 +102,182 @@ impl RegistryAdapter for MavenCentralAdapter { "Maven Central" } - async fn fetch_versions(&self, package: &str) -> Result, RegistryError> { - let mut versions = Vec::new(); - - // `core=gav` は timestamp 降順で返すため、1 ページで打ち切ると「最新 N 版」しか - // 得られない。古い系列に固定した依存 (`[1.11,1.12)` / `1.11.+`) の後継版が - // 候補に 1 件も入らず AlreadyLatest と誤判定されるので numFound まで辿る - for page in 0..MAX_PAGES { - let start = page * PAGE_ROWS; - let url = self.build_url(package, start)?; - let response: MavenSearchResponse = self - .client - .get_json(&url, package, self.registry_name()) - .await?; - - let body = response.response; - let fetched = body.docs.len() as u64; - - for doc in body.docs { - if let Some(released_at) = Self::timestamp_to_datetime(doc.timestamp) { - versions.push(VersionInfo::new(&doc.v, released_at)); - } - } + fn release_dates_deferred(&self) -> bool { + true + } - // このページが空、または全件取り切ったら終了 - if fetched == 0 || u64::from(start) + fetched >= body.num_found { - break; - } + async fn fetch_versions(&self, package: &str) -> Result, RegistryError> { + let url = format!("{}/maven-metadata.xml", self.artifact_url(package)?); + let xml = self + .client + .get_text(&url, package, self.registry_name()) + .await?; + let metadata: MavenMetadata = + quick_xml::de::from_str(&xml).map_err(|error| RegistryError::InvalidResponse { + package: package.to_string(), + registry: self.registry_name().to_string(), + message: format!("Maven metadata XML: {error}"), + })?; + let (group, artifact) = package.split_once(':').expect("validated coordinate"); + if metadata.group_id != group || metadata.artifact_id != artifact { + return Err(RegistryError::InvalidResponse { + package: package.to_string(), + registry: self.registry_name().to_string(), + message: "Maven metadata coordinate does not match request".to_string(), + }); } - // バージョンでソート + // The listing has no per-version timestamps. A conservative placeholder is + // replaced for each candidate before the judge makes a final decision. + let unknown_date = Utc.timestamp_opt(0, 0).single().expect("Unix epoch"); + let mut versions = metadata + .versioning + .versions + .version + .into_iter() + .map(|version| VersionInfo::new(version, unknown_date)) + .collect::>(); versions.sort(); - Ok(versions) } + + async fn fetch_release_date( + &self, + package: &str, + version: &str, + ) -> Result>, RegistryError> { + let url = self.version_url(package, version)?; + let response = self + .client + .get_response_with_retry( + || self.client.inner().head(&url), + package, + self.registry_name(), + ) + .await?; + if let Some(error) = crate::registry::client::map_status_error( + response.status(), + package, + self.registry_name(), + ) { + return Err(error); + } + let header = response.headers().get(LAST_MODIFIED).ok_or_else(|| { + RegistryError::InvalidResponse { + package: package.to_string(), + registry: self.registry_name().to_string(), + message: format!("POM for {version} has no Last-Modified header"), + } + })?; + let date = DateTime::parse_from_rfc2822(header.to_str().unwrap_or("")) + .map_err(|error| RegistryError::InvalidResponse { + package: package.to_string(), + registry: self.registry_name().to_string(), + message: format!("invalid Last-Modified for {version}: {error}"), + })? + .with_timezone(&Utc); + Ok(Some(date)) + } } #[cfg(test)] mod tests { use super::*; - use chrono::Datelike; - - #[test] - fn test_maven_central_adapter_language() { - let client = HttpClient::new().unwrap(); - let adapter = MavenCentralAdapter::new(client); - assert_eq!(adapter.language(), Language::Java); - } - - #[test] - fn test_maven_central_adapter_registry_name() { - let client = HttpClient::new().unwrap(); - let adapter = MavenCentralAdapter::new(client); - assert_eq!(adapter.registry_name(), "Maven Central"); - } - - #[test] - fn test_build_url() { - let client = HttpClient::new().unwrap(); - let adapter = MavenCentralAdapter::new(client); - let url = adapter - .build_url("org.apache.wicket:wicket-core", 0) - .unwrap(); - assert!(url.starts_with("https://search.maven.org/solrsearch/select")); - assert!(url.contains("q=g:org.apache.wicket+AND+a:wicket-core")); - assert!(url.contains("core=gav")); - assert!(url.contains("wt=json")); - assert!(url.contains("start=0")); - } - - /// ページングのオフセットが URL に反映される。 - /// `core=gav` は timestamp 降順なので、start を進めないと古い版に到達できない - #[test] - fn test_build_url_paging_offset() { - let client = HttpClient::new().unwrap(); - let adapter = MavenCentralAdapter::new(client); - let url = adapter.build_url("org.slf4j:slf4j-api", 200).unwrap(); - assert!(url.contains("start=200"), "{url}"); - assert!(url.contains(&format!("rows={}", PAGE_ROWS)), "{url}"); - } - - #[test] - fn test_build_url_invalid_format() { - let client = HttpClient::new().unwrap(); - let adapter = MavenCentralAdapter::new(client); - - // アーティファクトなし - let result = adapter.build_url("org.apache.wicket", 0); - assert!(result.is_err()); - - // パーツが多すぎる - let result = adapter.build_url("a:b:c", 0); - assert!(result.is_err()); + use std::io::{Read, Write}; + + fn serve_responses( + responses: Vec<(&'static str, &'static str, &'static str)>, + ) -> (String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + for (request_start, status_and_headers, body) in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).unwrap(); + assert!( + String::from_utf8_lossy(&request[..read]).starts_with(request_start), + "unexpected request: {}", + String::from_utf8_lossy(&request[..read]) + ); + let response = format!( + "HTTP/1.1 {status_and_headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + (format!("http://{address}"), handle) } - /// `numFound` がページ長を超えるレスポンスをデシリアライズできる - /// (このフィールドを読まないと 1 ページで打ち切って古い版を落とす) #[test] - fn test_response_body_exposes_num_found() { - let json = r#"{"response":{"numFound":106,"start":0,"docs":[{"v":"2.0.17","timestamp":1705314600000}]}}"#; - let parsed: MavenSearchResponse = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.response.num_found, 106); - assert_eq!(parsed.response.docs.len(), 1); + fn metadata_contains_versions_missing_from_search_index() { + let xml = r#"com.squareup.okhttp3okhttp5.4.05.5.020260816161727"#; + let parsed: MavenMetadata = quick_xml::de::from_str(xml).unwrap(); + assert_eq!(parsed.versioning.versions.version, ["5.4.0", "5.5.0"]); } #[test] - fn test_timestamp_to_datetime() { - // 日時変換の例: 2024-01-15T10:30:00Z = 1705314600000ミリ秒 - let timestamp_ms = 1705314600000_i64; - let dt = MavenCentralAdapter::timestamp_to_datetime(timestamp_ms).unwrap(); - assert_eq!(dt.year(), 2024); - assert_eq!(dt.month(), 1); - assert_eq!(dt.day(), 15); + fn repository_urls_are_validated() { + let adapter = MavenCentralAdapter::new(HttpClient::new().unwrap()); + assert_eq!( + adapter.artifact_url("com.squareup.okhttp3:okhttp").unwrap(), + "https://repo1.maven.org/maven2/com/squareup/okhttp3/okhttp" + ); + assert_eq!( + adapter + .version_url("com.squareup.okhttp3:okhttp", "5.5.0") + .unwrap(), + "https://repo1.maven.org/maven2/com/squareup/okhttp3/okhttp/5.5.0/okhttp-5.5.0.pom" + ); + assert!(adapter.artifact_url("../escape:okhttp").is_err()); + assert!(adapter.version_url("a:b", "../escape").is_err()); } - #[test] - fn test_timestamp_to_datetime_zero() { - let dt = MavenCentralAdapter::timestamp_to_datetime(0).unwrap(); - assert_eq!(dt.year(), 1970); - assert_eq!(dt.month(), 1); - assert_eq!(dt.day(), 1); + #[tokio::test] + async fn metadata_and_pom_date_use_repository_http_responses() { + let xml = "examplelibrary1.02.0"; + let (base_url, handle) = serve_responses(vec![ + ( + "GET /example/library/maven-metadata.xml ", + "200 OK\r\nContent-Type: application/xml\r\n", + xml, + ), + ( + "HEAD /example/library/2.0/library-2.0.pom ", + "200 OK\r\nLast-Modified: Sun, 16 Aug 2026 16:12:45 GMT\r\n", + "", + ), + ]); + let mut adapter = MavenCentralAdapter::new(HttpClient::new().unwrap()); + adapter.base_url = base_url; + let versions = adapter.fetch_versions("example:library").await.unwrap(); + assert_eq!(versions.len(), 2); + assert_eq!(versions[1].version, "2.0"); + let date = adapter + .fetch_release_date("example:library", "2.0") + .await + .unwrap() + .unwrap(); + assert_eq!(date.to_rfc3339(), "2026-08-16T16:12:45+00:00"); + handle.join().unwrap(); } - #[test] - fn test_deserialize_response() { - let json = r#" - { - "response": { - "docs": [ - {"v": "9.12.0", "timestamp": 1705314600000}, - {"v": "9.11.0", "timestamp": 1702722600000} - ] - } - } - "#; - - let response: MavenSearchResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.response.docs.len(), 2); - assert_eq!(response.response.docs[0].v, "9.12.0"); - assert_eq!(response.response.docs[0].timestamp, 1705314600000); + #[tokio::test] + async fn not_found_pom_does_not_use_its_last_modified_header() { + let (base_url, handle) = serve_responses(vec![( + "HEAD /example/library/2.0/library-2.0.pom ", + "404 Not Found\r\nLast-Modified: Sat, 11 Oct 2025 00:00:00 GMT\r\n", + "", + )]); + let mut adapter = MavenCentralAdapter::new(HttpClient::new().unwrap()); + adapter.base_url = base_url; + assert!( + adapter + .fetch_release_date("example:library", "2.0") + .await + .is_err() + ); + handle.join().unwrap(); } } diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 1cac56b..8c81e5f 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -51,6 +51,22 @@ pub trait RegistryAdapter: Send + Sync { /// パッケージの利用可能なバージョンを取得 async fn fetch_versions(&self, package: &str) -> Result, RegistryError>; + /// Whether `fetch_versions` returned placeholder dates that must be resolved + /// before an update can be reported. + fn release_dates_deferred(&self) -> bool { + false + } + + /// A release date that must be checked before selecting a version. Registries that + /// return dates with their version listing can use the default implementation. + async fn fetch_release_date( + &self, + _package: &str, + _version: &str, + ) -> Result>, RegistryError> { + Ok(None) + } + /// lock に存在する版の公開日。候補と違い、yank 済みの版も検証できる必要がある。 /// publisher_cutoff より若い版だけ、レジストリが確認した公開者も取得する。 async fn fetch_locked_versions( diff --git a/src/registry/versions.rs b/src/registry/versions.rs index 6f4e014..4eedcf6 100644 --- a/src/registry/versions.rs +++ b/src/registry/versions.rs @@ -3,6 +3,7 @@ use super::RegistryAdapter; use crate::domain::Language; use crate::update::VersionInfo; +use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{Mutex, Semaphore}; @@ -15,6 +16,7 @@ type VersionFetchLocks = Arc>>>> pub(crate) struct VersionFetcher { pub(crate) cache: VersionCache, fetch_locks: VersionFetchLocks, + release_dates: Mutex>>, general_semaphore: Semaphore, crates_io_semaphore: Semaphore, } @@ -24,6 +26,7 @@ impl VersionFetcher { Self { cache: Arc::new(Mutex::new(HashMap::new())), fetch_locks: Arc::new(Mutex::new(HashMap::new())), + release_dates: Mutex::new(HashMap::new()), general_semaphore: Semaphore::new(general_concurrency), crates_io_semaphore: Semaphore::new(crates_io_concurrency), } @@ -96,6 +99,40 @@ impl VersionFetcher { Ok(result) } + /// Share resolved publication dates across manifests in the same run. + pub(crate) async fn release_date( + &self, + adapter: &(dyn RegistryAdapter + Send + Sync), + package: &str, + version: &str, + ) -> Result>, String> { + let key = (adapter.language(), package.to_string(), version.to_string()); + if let Some(date) = self.release_dates.lock().await.get(&key) { + return Ok(Some(*date)); + } + let package_key = (adapter.language(), package.to_string()); + let fetch_lock = { + let mut locks = self.fetch_locks.lock().await; + Arc::clone( + locks + .entry(package_key) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + }; + let _guard = fetch_lock.lock().await; + if let Some(date) = self.release_dates.lock().await.get(&key) { + return Ok(Some(*date)); + } + let date = adapter + .fetch_release_date(package, version) + .await + .map_err(|error| error.to_string())?; + if let Some(date) = date { + self.release_dates.lock().await.insert(key, date); + } + Ok(date) + } + /// install がキャッシュにない版を選んだ場合に、共有レート制限付きで一覧を更新する。 pub(crate) async fn refresh( &self, diff --git a/src/update/mod.rs b/src/update/mod.rs index f0e36b6..f4e5b78 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -540,6 +540,23 @@ impl UpdateJudge { } } + /// Candidates that satisfy version constraints before the release-age check. + /// Used by registries that resolve publication dates lazily. + pub(crate) fn candidates_before_age<'a>( + &self, + dependency: &Dependency, + available_versions: &'a [VersionInfo], + ) -> Vec<&'a VersionInfo> { + let stable = self.stable_candidates(dependency, available_versions); + let flavored = apply_java_flavor_filter(dependency, stable); + let ranged = apply_range_upper_bound(dependency, flavored); + apply_rejected_versions(dependency, ranged) + } + + pub(crate) fn admits_age(&self, dependency: &Dependency, version: &VersionInfo) -> bool { + !self.apply_age_filter(dependency, vec![version]).is_empty() + } + /// `min_age` が設定されていれば、現在時刻から逆算したリリース時刻以前のものだけを残す。 fn apply_age_filter<'a>( &self,