diff --git a/AGENTS.md b/AGENTS.md index e752d54..289e4ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -324,3 +324,5 @@ make ci # CI と同じ検査 (fmt-check + lint + test、書き換 - Tauri バージョン同期の「既に一致」判定は npm パッケージ**全件**で行う。`@tauri-apps/api` と `@tauri-apps/cli` はパッチ集合差や age フィルタで judge 結果が非対称になりうるため、先頭 1 件 (依存はキー順に並ぶので通常 `api`) の一致で打ち切ると残りのずれが同期されない。OSV フォールバックで脆弱な候補を退けた結果 (`osv_skipped` が非空) も同期の上書きから保護する — 同期先はレジストリの生の一覧から最新を選ぶため、保護しないと judge が除外した脆弱版を選び直して OSV チェックを無効化してしまう - `--install` を走らせるディレクトリの順序はパス順に固定する。`HashMap` の `RandomState` 由来で実行のたびに入れ替わると、verbose 出力や失敗時の stderr の行順が変わって CI のログ比較で偽の差分になる - Rust の post-install age 監査では、fetch 失敗時も進捗位置を進める (末尾の数件が失敗するとバーが手前で止まったままになる)。差し戻しの `cargo update` は残り予算が `MIN_CARGO_UPDATE_SLICE` (10 秒) 未満なら着手しない — タイムアウトは残り予算でクランプされるので予算末尾で着手すると必ず失敗し、試行済みとして記録された組合せは二度と再試行されないため、本来差し戻せた依存が恒久的に「失敗」で確定する + +- 公開者による age 例外はグローバル設定の `[age_exempt].github` に閉じ、アカウント名を本体へ埋め込まない。`update/age_policy.rs` は版ごとの確認済み公開者(crates.io の GitHub ログイン一致 / Trusted Publishing)または Go・Swift の検証済み GitHub 取得元だけを照合する。自己申告 repository URL や現在の owner 一覧で過去の版を免除しない。候補選択・Tauri 同期・Rust post-install 監査(rollback 候補と最終 lock の照合を含む)で同じ `admits` 判定を共有する。例外は age のみを外し、プロジェクトの明示的な最小公開期間には優先しない。不正な例外設定は他のグローバル設定を維持したまま無効化する。 diff --git a/README.ja.md b/README.ja.md index ba1f023..72c322b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -210,7 +210,7 @@ depup は 3 か所から設定を読み取ります。どの設定も、コマ | 設定 | 置き場所 | 用途 | |---|---|---| -| グローバル設定 | `~/.config/depup/config.toml`(初回実行時に作成) | すべてのプロジェクトに共通する `age`・`osv`・`max_change` のデフォルト | +| グローバル設定 | `~/.config/depup/config.toml`(初回実行時に作成) | 共通する `age`・`osv`・`max_change` のデフォルトと、確認済み公開者の例外(`age_exempt`) | | モノレポのディレクトリ | プロジェクトのルートの `.depup` | 追加で処理するディレクトリ | | プロジェクトの age 設定 | pnpm・Bun の設定の `minimumReleaseAge`、mise の設定の `minimum_release_age` | そのプロジェクトの最小公開期間 | diff --git a/README.md b/README.md index f7b81ed..4212c98 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ depup reads settings from three places. For each setting, a command-line flag ov | Setting | Location | Purpose | |---|---|---| -| Global defaults | `~/.config/depup/config.toml`, created on the first run | Default `age`, `osv`, and `max_change` for every project | +| Global defaults | `~/.config/depup/config.toml`, created on the first run | Default `age`, `osv`, and `max_change`, plus verified publisher exceptions (`age_exempt`) | | Monorepo directories | `.depup` at the project root | Additional directories to process | | Project age policy | `minimumReleaseAge` in the pnpm or Bun settings, `minimum_release_age` in the mise settings | Minimum release age of that project | diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md index a2a264b..48a96ef 100644 --- a/docs/configuration.ja.md +++ b/docs/configuration.ja.md @@ -33,10 +33,36 @@ osv = true # "major"(デフォルト。すべて許可)のいずれかを指定します。 # 実行ごとに --max-change で上書きできます。 # max_change = "minor" + +# 確認済みの GitHub の身元で公開された版を、公開期間の制限から除外します。 +# 自己申告の repository URL は根拠にしません。詳細は docs/configuration.ja.md を参照してください。 +# [age_exempt] +# github = ["example-dev"] ``` ファイルにないキーには、組み込みデフォルト(`age = "1w"`、`osv = true`、`max_change` の制限なし)が使われます。ファイルを作成・解析できない場合は、警告を表示して組み込みデフォルトを使います。値が不正なキー(`age = "abc"` など)は、警告を表示したうえで、そのキーだけを組み込みデフォルトに戻します。 +## 確認済みの公開者による age の例外 + +通常の待機期間を維持し、指定した GitHub アカウントだけを例外にするには、グローバル設定に次のように書きます。 + +```toml +age = "2w" + +[age_exempt] +github = ["example-dev", "example-org"] +``` + +既定では例外は空です。ログイン名を完全一致で照合し、大文字・小文字は区別しません。不正な名前は警告して除外します。`age_exempt` テーブルの形式やキーが不正な場合も、例外だけを無効にし、他の設定は維持します。 + +対応する根拠は、crates.io の版ごとの公開者情報(GitHub ログイン名の一致が確認された `published_by`、または GitHub Trusted Publishing)と、Go・Swift の GitHub 取得元です。パッケージ名、別レジストリの同名アカウント、現在のクレート所有者一覧、自己申告の repository URL からは GitHub の身元を判断しません。他のレジストリには通常の age 制限を適用し、対象に含まれる場合は通知します。 + +Swift のタグには現状公開日の情報がなく、age フィルターはもともと実質適用されません。 + +例外が外すのは公開期間の制限だけです。ピン留め、バージョン範囲、プレリリースの扱い、`--max-change`、OSV チェックは引き続き適用します。プロジェクトに明示された `minimumReleaseAge` は優先され、グローバルの例外を無効にします。Git 依存は従来から公開期間の制限対象外です。 + +候補選択、Tauri の同期、`--install` 後の Rust lockfile 監査で同じ設定を使い、推移依存のクレートにも適用します。例外で更新する場合はテキスト出力に根拠を表示し、JSON 出力には `identity` と `evidence` を持つ `age_exemption` オブジェクトを追加します。 + ## `.depup` 設定ファイル 複数のサブディレクトリを持つモノレポでは、プロジェクトのルートに `.depup` ファイルを作成し、追加で処理するディレクトリを列挙できます。 diff --git a/docs/configuration.md b/docs/configuration.md index 5f8606f..b896333 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,10 +33,36 @@ osv = true # or "major" (default — all bumps allowed). # Override per-run with --max-change . # max_change = "minor" + +# Exempt releases published by verified GitHub identities from age filtering. +# Self-declared repository URLs do not qualify. See docs/configuration.md. +# [age_exempt] +# github = ["example-dev"] ``` A key that is missing from the file falls back to the built-in default (`age = "1w"`, `osv = true`, no `max_change` limit). If the file cannot be created or parsed, depup prints a warning and uses the built-in defaults. If a single value is invalid (such as `age = "abc"`), depup prints a warning and uses the built-in default for that setting only. +## Age Exemptions by Verified Publisher + +Keep the normal waiting period and exempt selected GitHub identities in your global configuration: + +```toml +age = "2w" + +[age_exempt] +github = ["example-dev", "example-org"] +``` + +The list is empty by default. Logins are matched as complete names, without case sensitivity. Invalid names are ignored with a warning; an invalid `age_exempt` table disables only the exemptions and preserves your other settings. + +Supported evidence is version-specific crates.io publisher information (`published_by` with a confirmed GitHub login, or GitHub Trusted Publishing), and GitHub source paths for Go and Swift. A package name, a registry account with the same name, a current list of crate owners, or a self-declared repository URL does not establish a GitHub identity. Other registries keep the normal age filter; depup prints a notice when they are selected. + +Swift tags currently have no release-date metadata, so their age filter already has no practical effect. + +Exemptions affect only release age. Pinned dependencies, version ranges, prerelease rules, `--max-change`, and OSV checks still apply. An explicit project `minimumReleaseAge` takes precedence and disables global exemptions. Git dependencies already bypass release-age filtering. + +The same policy applies to candidate selection, Tauri synchronization, and the Rust lockfile audit after `--install`, including transitive crates. Text output identifies an exempt update; JSON adds an `age_exemption` object with `identity` and `evidence` only when an update uses the exemption. + ## `.depup` Configuration File For monorepo projects with multiple subdirectories, create a `.depup` file at the project root to list additional directories to process: diff --git a/src/domain/update_result.rs b/src/domain/update_result.rs index bd3a97a..4410423 100644 --- a/src/domain/update_result.rs +++ b/src/domain/update_result.rs @@ -70,6 +70,9 @@ pub enum UpdateResult { /// API エラー時) を意味する。 #[serde(default, skip_serializing_if = "std::ops::Not::not")] osv_checked: bool, + /// age を免除した身元と、レジストリが確認した根拠。 + #[serde(default, skip_serializing_if = "Option::is_none")] + age_exemption: Option, }, /// 依存関係の更新がスキップされた Skip { @@ -84,6 +87,13 @@ pub enum UpdateResult { } impl UpdateResult { + pub fn with_age_exemption(mut self, exemption: Option) -> Self { + if let Self::Update { age_exemption, .. } = &mut self { + *age_exemption = exemption; + } + self + } + /// リリース日なしのUpdate結果を作成する (後方互換性のため) pub fn update(dependency: Dependency, new_version: impl Into) -> Self { UpdateResult::Update { @@ -92,6 +102,7 @@ impl UpdateResult { released_at: None, osv_skipped: Vec::new(), osv_checked: false, + age_exemption: None, } } @@ -107,6 +118,7 @@ impl UpdateResult { released_at: Some(released_at), osv_skipped: Vec::new(), osv_checked: false, + age_exemption: None, } } diff --git a/src/global_config.rs b/src/global_config.rs index 1b52a5c..af780ab 100644 --- a/src/global_config.rs +++ b/src/global_config.rs @@ -1,6 +1,6 @@ //! グローバル設定ファイル (`~/.config/depup/config.toml`) のローダ //! -//! ユーザー単位のデフォルトを定義する。現状は `age` と `osv` をサポート。 +//! ユーザー単位の age・公開者の例外・OSV・変更レベルのデフォルトを定義する。 //! ファイルが無い場合は初回読み込み時にコメント付きの雛形を自動生成する //! (生成に失敗してもツールの動作は続行され、組み込みデフォルトが使われる)。 @@ -51,6 +51,11 @@ osv = true # or "major" (default — all bumps allowed). # Override per-run with --max-change . # max_change = "minor" + +# Exempt releases published by verified GitHub identities from age filtering. +# Self-declared repository URLs do not qualify. See docs/configuration.md. +# [age_exempt] +# github = ["example-dev"] "#; /// `~/.config/depup/config.toml` の内容。 @@ -73,6 +78,28 @@ pub struct GlobalConfig { /// 未指定の場合は制限なし (= major bumps も許可)。 #[serde(default)] pub max_change: Option, + + /// age だけを免除する確認済みの公開者。 + #[serde(default, deserialize_with = "deserialize_age_exempt")] + pub age_exempt: crate::update::AgeExemptions, +} + +fn deserialize_age_exempt<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = toml::Value::deserialize(deserializer)?; + match value.try_into::() { + Ok(mut exemptions) => { + exemptions.validate(); + Ok(exemptions) + } + Err(error) => { + // 例外の typo で age=2w 等の他の設定まで失われないようにする。 + eprintln!("Warning: invalid age_exempt configuration; exemptions disabled: {error}"); + Ok(crate::update::AgeExemptions::default()) + } + } } impl GlobalConfig { @@ -210,6 +237,27 @@ pub fn resolve_osv(cli_osv: bool, no_osv: bool, config: Option<&GlobalConfig>) - #[cfg(test)] mod tests { + #[test] + fn age_exempt_normalizes_logins_and_preserves_other_settings_on_errors() { + let config: super::GlobalConfig = toml::from_str("age = '2w'\nosv = true\n[age_exempt]\ngithub = ['EXAMPLE-dev', 'example-dev', 'bad/name']").unwrap(); + assert_eq!(config.age.as_deref(), Some("2w")); + assert_eq!(config.age_exempt.github, ["example-dev"]); + for nested in [ + "github = 42", + "githb = ['example-dev']", + "github = ['example-dev', 42]", + ] { + let config: super::GlobalConfig = + toml::from_str(&format!("age = '2w'\nosv = true\n[age_exempt]\n{nested}")).unwrap(); + assert_eq!( + config.age_duration(), + Some(std::time::Duration::from_secs(14 * 86400)) + ); + assert_eq!(config.osv, Some(true)); + assert!(config.age_exempt.is_empty()); + } + } + use super::*; use tempfile::TempDir; diff --git a/src/orchestrator.rs b/src/orchestrator.rs index d9aab52..9ec4b56 100644 --- a/src/orchestrator.rs +++ b/src/orchestrator.rs @@ -394,6 +394,7 @@ impl Orchestrator { budget, } = *audit; let lock_path = project_dir.join("Cargo.lock"); + let exemptions = self.resolved_age_exemptions(); let mut log = AdjustmentLog::default(); // 1 件ずつの `--precise` を試した組合せ。成否によらず 1 件ずつは再試行しない。 // resolver 制約で失敗した組合せを 1 件ずつ試し直すと、パスごとに同じ @@ -495,7 +496,7 @@ impl Orchestrator { continue; }; - if current_info.released_at <= cutoff { + if exemptions.admits(Language::Rust, name, current_info, cutoff) { continue; } @@ -505,6 +506,7 @@ impl Orchestrator { cutoff, preferred.get(name).map(Vec::as_slice), baseline.get(name).map(Vec::as_slice), + &exemptions, ) else { log.record(name, current, None, LockAgeStatus::NoOlderCandidate); continue; @@ -779,7 +781,7 @@ impl Orchestrator { let young = known.is_some_and(|all| { all.iter().any(|info| { compare_versions(&info.version, &to) == std::cmp::Ordering::Equal - && info.released_at > cutoff + && !exemptions.admits(Language::Rust, &name, info, cutoff) }) }); if young { @@ -801,14 +803,18 @@ impl Orchestrator { }) }); match (known.as_ref(), info) { - (Some(_), Some(info)) if info.released_at > cutoff => log.record( - &name, - &version, - None, - LockAgeStatus::NotAttempted( - "the audit ended before rolling it back".to_string(), - ), - ), + (Some(_), Some(info)) + if !exemptions.admits(Language::Rust, &name, info, cutoff) => + { + log.record( + &name, + &version, + None, + LockAgeStatus::NotAttempted( + "the audit ended before rolling it back".to_string(), + ), + ) + } // 期間を満たす版、または crates.io の一覧に無い版 (yank 済みなど) は // 監査中と同じく違反として扱わない (Some(_), _) => {} @@ -1031,7 +1037,7 @@ impl Orchestrator { }; } - let judge = UpdateJudge::new(self.build_filter()); + let filter = self.build_filter(); // mise のバージョン解決は `mise` コマンドに委譲するため、未インストールなら // 依存ごとに同じ fetch エラーを並べる前にマニフェストごと外す。 @@ -1039,6 +1045,21 @@ impl Orchestrator { let manifests = manifests.as_slice(); let parsed = self.parse_phase(manifests, progress, &mut errors); + if filter.min_age.is_some() + && !filter.age_exempt.is_empty() + && parsed.iter().any(|manifest| { + !manifest.dependencies.is_empty() + && !matches!( + manifest.info.language, + Language::Rust | Language::Go | Language::Swift + ) + }) + { + eprintln!( + "Notice: age_exempt.github is unavailable for other registries; their dependencies retain the age filter" + ); + } + let judge = UpdateJudge::new(filter); self.check_phase(parsed, &judge, progress, &mut summary, &mut errors) .await; self.sync_tauri_if_needed(manifests, progress, &mut summary, &mut errors) @@ -1235,6 +1256,14 @@ impl Orchestrator { if let Some(age) = resolved.duration { filter = filter.with_min_age(age); } + filter.age_exempt = self.resolved_age_exemptions(); + if let Some(config) = &self.global_config + && !config.age_exempt.is_empty() + && filter.min_age.is_some() + && filter.age_exempt.is_empty() + { + eprintln!("Warning: project minimumReleaseAge takes precedence over age_exempt"); + } // mise の除外設定・ツール単位の age は depup 側で解釈しないため、食い違いを通知する self.warn_mise_age_excludes(); self.warn_mise_tool_age_overrides(); @@ -1268,6 +1297,16 @@ impl Orchestrator { self.resolve_age().duration } + fn resolved_age_exemptions(&self) -> crate::update::AgeExemptions { + if self.read_project_minimum_release_age().is_some() { + return crate::update::AgeExemptions::default(); + } + self.global_config + .as_ref() + .map(|config| config.age_exempt.clone()) + .unwrap_or_default() + } + /// プロジェクト直下の minimumReleaseAge 設定を読む。 /// pnpm (`pnpm-workspace.yaml` / `.npmrc` / `package.json`)、 /// bun (`bunfig.toml`)、mise (`mise.toml` などの `[settings]`) を見て、 @@ -1548,7 +1587,7 @@ impl Orchestrator { }; // 同期ヘルパーを作成し、同期後のバージョンを取得 - let sync = TauriVersionSync::new(npm_versions, crate_versions); + let sync = TauriVersionSync::new(npm_versions, crate_versions.clone()); let npm_update_result = npm_packages.first().map(|(_, _, r, _)| r); let crate_update_result = crate_info.as_ref().map(|(_, _, r, _)| r); @@ -1576,6 +1615,17 @@ impl Orchestrator { *result_idx, original, target.clone(), + cutoff + .and_then(|cutoff| { + crate_versions + .iter() + .find(|info| &info.version == target) + .filter(|info| info.released_at > cutoff) + }) + .and_then(|info| { + self.resolved_age_exemptions() + .exemption(Language::Rust, TAURI_CRATE, info) + }), ); } } @@ -1622,8 +1672,20 @@ impl Orchestrator { // (同期が age ポリシーを迂回して新しすぎるバージョンを書かないように) let cutoff = self.resolved_min_age().and_then(crate::domain::cutoff_now); Some(( - filter_versions_by_cutoff(npm_versions, cutoff), - filter_versions_by_cutoff(crate_versions, cutoff), + filter_versions_by_cutoff( + npm_versions, + cutoff, + Language::Node, + TAURI_NPM_PACKAGES[0], + &self.resolved_age_exemptions(), + ), + filter_versions_by_cutoff( + crate_versions, + cutoff, + Language::Rust, + TAURI_CRATE, + &self.resolved_age_exemptions(), + ), cutoff, )) } @@ -1648,7 +1710,13 @@ impl Orchestrator { } else { match self.fetch_versions(&*npm_adapter, pkg_name).await { Ok(vs) => { - let vs = filter_versions_by_cutoff(vs, cutoff); + let vs = filter_versions_by_cutoff( + vs, + cutoff, + Language::Node, + pkg_name, + &self.resolved_age_exemptions(), + ); pick_sync_version(&vs, target) } // フェッチできない場合はこのパッケージの同期を見送る @@ -1658,7 +1726,14 @@ impl Orchestrator { let Some(pkg_target) = pkg_target else { continue; }; - apply_sync_adjustment(summary, *manifest_idx, *result_idx, original, pkg_target); + apply_sync_adjustment( + summary, + *manifest_idx, + *result_idx, + original, + pkg_target, + None, + ); } } @@ -1912,6 +1987,7 @@ fn rollback_target( cutoff: chrono::DateTime, preferred: Option<&[String]>, before: Option<&[String]>, + exemptions: &crate::update::AgeExemptions, ) -> Option { let floor = version_floor(before, current); let at_least_floor = |version: &str| { @@ -1928,14 +2004,14 @@ fn rollback_target( .filter(|version| { available.iter().any(|info| { compare_versions(&info.version, version) == std::cmp::Ordering::Equal - && info.released_at <= cutoff + && exemptions.admits(Language::Rust, "", info, cutoff) }) }) .max_by(|a, b| compare_versions(a, b)); if let Some(version) = from_judge { return Some(version.clone()); } - match pick_older_within_age(available, current, cutoff) { + match pick_older_within_age(available, current, cutoff, exemptions) { Some(version) if at_least_floor(&version) => Some(version), // 期間を満たす版が下限より古い (または無い) なら、install 前の版に戻す _ => floor, @@ -2137,13 +2213,14 @@ fn pick_older_within_age( available: &[VersionInfo], current: &str, cutoff: chrono::DateTime, + exemptions: &crate::update::AgeExemptions, ) -> Option { let mut best: Option<&VersionInfo> = None; for v in available { if v.is_prerelease() { continue; } - if v.released_at > cutoff { + if !exemptions.admits(Language::Rust, "", v, cutoff) { continue; } // 現在の lock バージョンと同じもしくはそれより新しいものは対象外 @@ -2322,6 +2399,7 @@ fn apply_sync_adjustment( result_idx: usize, original: &UpdateResult, target: String, + exemption: Option, ) { // 同期先が現在版と同一なら Update を作らない。 // judge 側は「書き換え結果が現在の raw と同一なら AlreadyLatest」という不変条件を @@ -2332,7 +2410,8 @@ fn apply_sync_adjustment( return; } - let adjusted = UpdateResult::update(original.dependency().clone(), target); + let adjusted = + UpdateResult::update(original.dependency().clone(), target).with_age_exemption(exemption); summary.manifests[manifest_idx].results[result_idx] = adjusted; if matches!(original, UpdateResult::Skip { .. }) { summary.manifests[manifest_idx].modified = true; @@ -2343,11 +2422,14 @@ fn apply_sync_adjustment( fn filter_versions_by_cutoff( versions: Vec, cutoff: Option>, + language: Language, + package: &str, + exemptions: &crate::update::AgeExemptions, ) -> Vec { match cutoff { Some(c) => versions .into_iter() - .filter(|v| v.released_at <= c) + .filter(|v| exemptions.admits(language, package, v, c)) .collect(), None => versions, } @@ -2739,7 +2821,7 @@ mod git_helper_tests { ]; let cutoff = chrono::Utc::now() - chrono::Duration::days(14); assert_eq!( - pick_older_within_age(&versions, "1.5.0", cutoff).as_deref(), + pick_older_within_age(&versions, "1.5.0", cutoff, &Default::default()).as_deref(), Some("1.4.9"), ); } @@ -2749,7 +2831,7 @@ mod git_helper_tests { // 全候補が age 違反または現バージョン以上 let versions = vec![version_at("1.5.0", 3), version_at("1.6.0", 1)]; let cutoff = chrono::Utc::now() - chrono::Duration::days(14); - assert!(pick_older_within_age(&versions, "1.5.0", cutoff).is_none()); + assert!(pick_older_within_age(&versions, "1.5.0", cutoff, &Default::default()).is_none()); } #[test] @@ -2762,7 +2844,7 @@ mod git_helper_tests { ]; let cutoff = chrono::Utc::now() - chrono::Duration::days(14); assert_eq!( - pick_older_within_age(&versions, "1.5.0", cutoff).as_deref(), + pick_older_within_age(&versions, "1.5.0", cutoff, &Default::default()).as_deref(), Some("1.4.9"), ); } @@ -2778,7 +2860,7 @@ mod git_helper_tests { ]; let cutoff = chrono::Utc::now() - chrono::Duration::days(14); assert_eq!( - pick_older_within_age(&versions, "2.0.0", cutoff).as_deref(), + pick_older_within_age(&versions, "2.0.0", cutoff, &Default::default()).as_deref(), Some("1.4.9"), ); } @@ -2789,7 +2871,7 @@ mod git_helper_tests { let versions = vec![version_at("1.4.0", 30), version_at("1.5.0", 3)]; let cutoff = chrono::Utc::now() - chrono::Duration::days(14); assert_eq!( - pick_older_within_age(&versions, "1.5.0", cutoff).as_deref(), + pick_older_within_age(&versions, "1.5.0", cutoff, &Default::default()).as_deref(), Some("1.4.0"), ); } @@ -2806,7 +2888,15 @@ mod git_helper_tests { let cutoff = chrono::Utc::now() - chrono::Duration::days(14); let preferred = vec!["1.4.5".to_string()]; assert_eq!( - rollback_target(&versions, "1.5.0", cutoff, Some(&preferred), None).as_deref(), + rollback_target( + &versions, + "1.5.0", + cutoff, + Some(&preferred), + None, + &Default::default() + ) + .as_deref(), Some("1.4.5"), ); } @@ -2830,13 +2920,21 @@ mod git_helper_tests { vec!["0.9.0".to_string()], ] { assert_eq!( - rollback_target(&versions, "1.5.0", cutoff, Some(&preferred), None).as_deref(), + rollback_target( + &versions, + "1.5.0", + cutoff, + Some(&preferred), + None, + &Default::default() + ) + .as_deref(), Some("1.4.9"), "preferred = {preferred:?}", ); } assert_eq!( - rollback_target(&versions, "1.5.0", cutoff, None, None).as_deref(), + rollback_target(&versions, "1.5.0", cutoff, None, None, &Default::default()).as_deref(), Some("1.4.9"), ); } @@ -2852,7 +2950,15 @@ mod git_helper_tests { let cutoff = chrono::Utc::now() - chrono::Duration::days(14); let preferred = vec!["1.4.0".to_string(), "1.4.5".to_string()]; assert_eq!( - rollback_target(&versions, "1.5.0", cutoff, Some(&preferred), None).as_deref(), + rollback_target( + &versions, + "1.5.0", + cutoff, + Some(&preferred), + None, + &Default::default() + ) + .as_deref(), Some("1.4.5"), ); } @@ -2871,15 +2977,29 @@ mod git_helper_tests { let preferred = vec!["1.40.5".to_string()]; let before = vec!["1.50.0".to_string()]; assert_eq!( - rollback_target(&versions, "1.53.1", cutoff, Some(&preferred), Some(&before)) - .as_deref(), + rollback_target( + &versions, + "1.53.1", + cutoff, + Some(&preferred), + Some(&before), + &Default::default() + ) + .as_deref(), Some("1.52.0"), ); // 別系列の install 前の版は下限にしない let before = vec!["0.9.0".to_string()]; assert_eq!( - rollback_target(&versions, "1.53.1", cutoff, Some(&preferred), Some(&before)) - .as_deref(), + rollback_target( + &versions, + "1.53.1", + cutoff, + Some(&preferred), + Some(&before), + &Default::default() + ) + .as_deref(), Some("1.40.5"), ); } @@ -2997,13 +3117,29 @@ mod git_helper_tests { let cutoff = chrono::Utc::now() - chrono::Duration::days(14); let before = vec!["1.50.0".to_string()]; assert_eq!( - rollback_target(&versions, "1.50.1", cutoff, None, Some(&before)).as_deref(), + rollback_target( + &versions, + "1.50.1", + cutoff, + None, + Some(&before), + &Default::default() + ) + .as_deref(), Some("1.50.0"), ); let only_young = vec![version_at("1.50.0", 5), version_at("1.50.1", 2)]; assert_eq!( - rollback_target(&only_young, "1.50.1", cutoff, None, Some(&before)).as_deref(), + rollback_target( + &only_young, + "1.50.1", + cutoff, + None, + Some(&before), + &Default::default() + ) + .as_deref(), Some("1.50.0"), ); } @@ -3011,6 +3147,115 @@ mod git_helper_tests { #[cfg(test)] mod tests { + #[test] + fn explicit_project_age_overrides_global_publisher_exemptions() { + use crate::domain::{VersionSpec, VersionSpecKind}; + let dir = TempDir::new().unwrap(); + let config = GlobalConfig { + age: Some("2w".into()), + age_exempt: crate::update::AgeExemptions { + github: vec!["example-dev".into()], + }, + ..Default::default() + }; + let orchestrator = Orchestrator::new(make_args_with_path(dir.path(), &["--rust"])) + .unwrap() + .with_global_config(Some(config)); + let dependency = Dependency::new( + "library", + VersionSpec::new(VersionSpecKind::Caret, "^1.0.0", "1.0.0"), + false, + Language::Rust, + ); + let mut young = VersionInfo::now("1.1.0"); + young.publisher = crate::update::PublisherEvidence::GithubUser { + login: "example-dev".into(), + }; + let versions = [ + VersionInfo::new("1.0.0", chrono::Utc::now() - chrono::Duration::days(30)), + young, + ]; + assert!( + UpdateJudge::new(orchestrator.build_filter()) + .judge(&dependency, &versions) + .is_update() + ); + fs::write( + dir.path().join("pnpm-workspace.yaml"), + "packages: []\nminimumReleaseAge: 14400\n", + ) + .unwrap(); + assert!( + UpdateJudge::new(orchestrator.build_filter()) + .judge(&dependency, &versions) + .is_skip() + ); + assert!(orchestrator.resolved_age_exemptions().is_empty()); + assert_eq!( + orchestrator.resolved_min_age(), + Some(Duration::from_secs(10 * 86400)) + ); + } + + #[test] + fn rollback_can_select_a_young_verified_release() { + let version_at = |version: &str, days: i64| { + VersionInfo::new(version, chrono::Utc::now() - chrono::Duration::days(days)) + }; + let cutoff = chrono::Utc::now() - chrono::Duration::days(14); + let exemptions = crate::update::AgeExemptions { + github: vec!["example-dev".into()], + }; + let mut young = version_at("1.4.9", 1); + young.publisher = crate::update::PublisherEvidence::GithubUser { + login: "example-dev".into(), + }; + let versions = vec![version_at("1.4.5", 30), young, version_at("1.5.0", 1)]; + assert_eq!( + pick_older_within_age(&versions, "1.5.0", cutoff, &exemptions).as_deref(), + Some("1.4.9") + ); + let preferred = ["1.4.9".into()]; + assert_eq!( + rollback_target( + &versions, + "1.5.0", + cutoff, + Some(&preferred), + None, + &exemptions + ) + .as_deref(), + Some("1.4.9") + ); + assert_eq!( + pick_older_within_age(&versions, "1.5.0", cutoff, &Default::default()).as_deref(), + Some("1.4.5") + ); + assert_eq!( + filter_versions_by_cutoff( + versions.clone(), + Some(cutoff), + Language::Rust, + "library", + &exemptions + ) + .len(), + 2 + ); + assert_eq!( + filter_versions_by_cutoff( + versions, + Some(cutoff), + Language::Node, + "library", + &exemptions + ) + .len(), + 1 + ); + } + use super::*; use async_trait::async_trait; use clap::Parser; @@ -3474,6 +3719,7 @@ mod tests { vec![VersionInfo { version: "4.17.21".to_string(), released_at: chrono::Utc::now(), + publisher: Default::default(), }], ); } @@ -3557,6 +3803,69 @@ mod lock_age_audit_tests { use chrono::{DateTime, TimeZone, Utc}; use clap::Parser; + #[tokio::test] + async fn verified_direct_and_transitive_releases_survive_final_lock_audit() { + let registry = LocalRegistry::new(); + for version in ["1.0.0", "1.0.1"] { + registry.publish("trusted-child", version, &[]); + registry.publish("trusted-parent", version, &[("trusted-child", version)]); + registry.publish("ordinary", version, &[]); + } + let project = TestProject::new( + ®istry, + &manifest("trusted-parent = '=1.0.0'\nordinary = '=1.0.0'\n"), + ); + let unpinned = manifest("trusted-parent = '1.0.0'\nordinary = '1.0.0'\n"); + let baseline = install(&project, &unpinned); + let mut dates = FakeCratesIo::new(); + for name in ["trusted-parent", "trusted-child", "ordinary"] { + dates = dates + .release(name, "1.0.0", old()) + .release(name, "1.0.1", fresh()); + } + for name in ["trusted-parent", "trusted-child"] { + dates = dates.publisher( + name, + "1.0.1", + crate::update::PublisherEvidence::GithubTrustedPublisher { + owner: "example-dev".into(), + }, + ); + } + let config = crate::global_config::GlobalConfig { + age: Some("2w".into()), + age_exempt: crate::update::AgeExemptions { + github: vec!["example-dev".into()], + }, + ..Default::default() + }; + let orchestrator = Orchestrator::new(CliArgs::parse_from(["depup"])) + .unwrap() + .with_global_config(Some(config)); + let cargo = cargo_for(&project); + let result = orchestrator + .audit_lock_age( + &LockAgeAudit { + project_dir: project.path(), + cutoff: cutoff(), + baseline: &baseline, + preferred: &PreferredVersions::new(), + adapter: &dates, + cargo: &cargo, + budget: LOCK_AGE_AUDIT_BUDGET, + }, + None, + ) + .await; + assert_eq!(project.locked_versions("trusted-parent"), ["1.0.1"]); + assert_eq!(project.locked_versions("trusted-child"), ["1.0.1"]); + assert_eq!(project.locked_versions("ordinary"), ["1.0.0"]); + assert_eq!(result.unchecked, 0); + assert_eq!(result.adjustments.len(), 1); + assert_eq!(project.read("Cargo.toml"), unpinned); + assert!(lock_is_accepted(&project)); + } + /// この日時より後に公開された版を違反とする fn cutoff() -> DateTime { Utc.with_ymd_and_hms(2026, 9, 16, 0, 0, 0).unwrap() diff --git a/src/output/json.rs b/src/output/json.rs index 24ffdf7..11b9a51 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -79,6 +79,8 @@ struct JsonManifest { /// 更新の JSON 表現 #[derive(Serialize)] struct JsonUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + age_exemption: Option, /// パッケージ名 name: String, /// 種別: `registry` または `git` @@ -167,6 +169,7 @@ impl JsonFormatter { if let UpdateResult::Update { dependency, new_version, + age_exemption, .. } = result { @@ -176,6 +179,7 @@ impl JsonFormatter { _ => git.current_commit.clone().unwrap_or_default(), }; Some(JsonUpdate { + age_exemption: age_exemption.clone(), name: dependency.name.clone(), kind: "git", from, @@ -190,6 +194,7 @@ impl JsonFormatter { }) } else { Some(JsonUpdate { + age_exemption: age_exemption.clone(), name: dependency.name.clone(), kind: "registry", // `to` はレジストリが返す生の値なので、`from` も同じ書式 @@ -322,6 +327,38 @@ impl OutputFormatter for JsonFormatter { #[cfg(test)] mod tests { + #[test] + fn exemption_evidence_is_visible_in_json_and_text_only_when_used() { + let exemption = crate::update::AgeExemption { + identity: "github:example-dev".into(), + evidence: "crates_io_publisher".into(), + }; + let mut manifest = + ManifestUpdateResult::new(std::path::PathBuf::from("Cargo.toml"), Language::Rust); + manifest.add_result( + UpdateResult::update(sample_dependency("library", "1.0.0"), "1.1.0") + .with_age_exemption(Some(exemption.clone())), + ); + manifest.add_result(UpdateResult::update( + sample_dependency("ordinary", "1.0.0"), + "1.1.0", + )); + let output = JsonFormatter::new(Verbosity::Normal).manifest_to_json(&manifest); + let json = serde_json::to_value(output).unwrap(); + assert_eq!( + json["updates"][0]["age_exemption"]["identity"], + exemption.identity + ); + assert!(json["updates"][1].get("age_exemption").is_none()); + let mut text = Vec::new(); + crate::output::TextFormatter::with_color(Verbosity::Normal, false, false) + .format_manifest(&manifest, &mut text) + .unwrap(); + let text = String::from_utf8(text).unwrap(); + assert!(text.contains("age exempt: github:example-dev (crates_io_publisher)")); + assert_eq!(text.matches("age exempt:").count(), 1); + } + use super::*; use crate::domain::{Dependency, GitSource, VersionSpec, VersionSpecKind}; use std::path::PathBuf; diff --git a/src/output/text.rs b/src/output/text.rs index 138aab4..d72f703 100644 --- a/src/output/text.rs +++ b/src/output/text.rs @@ -336,6 +336,7 @@ impl TextFormatter { released_at, osv_skipped, osv_checked, + age_exemption, } = result { // git 依存は専用フォーマットで表示 @@ -371,6 +372,13 @@ impl TextFormatter { max_name_len, writer, )?; + if let Some(exemption) = age_exemption { + writeln!( + writer, + " age exempt: {} ({})", + exemption.identity, exemption.evidence + )?; + } } } Ok(()) diff --git a/src/registry/crates_io.rs b/src/registry/crates_io.rs index 4e0b656..1520cd4 100644 --- a/src/registry/crates_io.rs +++ b/src/registry/crates_io.rs @@ -75,6 +75,39 @@ struct CrateVersion { created_at: String, /// このバージョンが yank されているか yanked: bool, + #[serde(default)] + published_by: serde_json::Value, + #[serde(default)] + trustpub_data: serde_json::Value, +} + +impl CrateVersion { + fn publisher(&self) -> crate::update::PublisherEvidence { + use crate::update::PublisherEvidence; + if self.trustpub_data["provider"].as_str() == Some("github") + && let Some(repository) = self.trustpub_data["repository"].as_str() + && let Some((owner, repo)) = repository.split_once('/') + && crate::update::age_policy::valid_github_login(owner) + && crate::registry::is_valid_registry_id_segment(repo) + { + return PublisherEvidence::GithubTrustedPublisher { + owner: owner.into(), + }; + } + // Trusted Publishing の実行元が不明なら、設定を作った利用者へ帰属させない。 + if !self.trustpub_data.is_null() { + return PublisherEvidence::Unknown; + } + if self.published_by["github_username_matches"].as_bool() == Some(true) + && let Some(login) = self.published_by["login"].as_str() + && crate::update::age_policy::valid_github_login(login) + { + return PublisherEvidence::GithubUser { + login: login.into(), + }; + } + PublisherEvidence::Unknown + } } impl CratesIoAdapter { @@ -174,7 +207,9 @@ impl RegistryAdapter for CratesIoAdapter { } if let Ok(released_at) = version.created_at.parse::>() { - versions.push(VersionInfo::new(&version.num, released_at)); + let mut info = VersionInfo::new(&version.num, released_at); + info.publisher = version.publisher(); + versions.push(info); } } @@ -189,6 +224,50 @@ impl RegistryAdapter for CratesIoAdapter { mod tests { use super::*; + #[test] + fn publisher_requires_registry_verified_identity() { + use crate::update::PublisherEvidence; + let base = + serde_json::json!({"num":"1.2.3", "created_at":"2026-01-01T00:00:00Z", "yanked":false}); + let parse = |metadata: serde_json::Value| { + let mut value = base.clone(); + value + .as_object_mut() + .unwrap() + .extend(metadata.as_object().unwrap().clone()); + serde_json::from_value::(value) + .unwrap() + .publisher() + }; + assert_eq!( + parse( + serde_json::json!({"published_by":{"login":"example-dev", "github_username_matches":true}}) + ), + PublisherEvidence::GithubUser { + login: "example-dev".into() + } + ); + assert_eq!( + parse( + serde_json::json!({"trustpub_data":{"provider":"github", "repository":"example-dev/library"}}) + ), + PublisherEvidence::GithubTrustedPublisher { + owner: "example-dev".into() + } + ); + for metadata in [ + serde_json::json!({}), + serde_json::json!({"published_by":{"login":"example-dev"}}), + serde_json::json!({"published_by":{"login":"example-dev", "github_username_matches":false}}), + serde_json::json!({"repository":"https://github.com/example-dev/library"}), + serde_json::json!({"published_by":"example-dev"}), + serde_json::json!({"trustpub_data":{"provider":"github", "repository":"example-dev/library/extra"}}), + serde_json::json!({"trustpub_data":{"provider":"gitlab", "repository":"example-dev/library"}, "published_by":{"login":"example-dev", "github_username_matches":true}}), + ] { + assert_eq!(parse(metadata), PublisherEvidence::Unknown); + } + } + #[test] fn test_crates_io_adapter_language() { let client = HttpClient::new().unwrap(); diff --git a/src/test_support/fake_crates_io.rs b/src/test_support/fake_crates_io.rs index b723a63..ca57633 100644 --- a/src/test_support/fake_crates_io.rs +++ b/src/test_support/fake_crates_io.rs @@ -22,6 +22,22 @@ pub(crate) struct FakeCratesIo { } impl FakeCratesIo { + pub fn publisher( + mut self, + name: &str, + version: &str, + publisher: crate::update::PublisherEvidence, + ) -> Self { + self.releases + .get_mut(name) + .unwrap() + .iter_mut() + .find(|info| info.version == version) + .unwrap() + .publisher = publisher; + self + } + /// 版を 1 つも持たない偽のレジストリを作る pub fn new() -> Self { Self { diff --git a/src/update/age_policy.rs b/src/update/age_policy.rs new file mode 100644 index 0000000..2c07468 --- /dev/null +++ b/src/update/age_policy.rs @@ -0,0 +1,200 @@ +//! 設定された身元と、レジストリが確認した公開者・取得元を照合する age ポリシー。 + +use crate::domain::Language; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::VersionInfo; + +/// GitHub の身元をレジストリが確認した、版ごとの公開者情報。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PublisherEvidence { + #[default] + Unknown, + /// crates.io が GitHub と一致すると確認したログイン名。 + GithubUser { login: String }, + /// crates.io の Trusted Publishing が検証した GitHub リポジトリの所有者。 + GithubTrustedPublisher { owner: String }, +} + +impl PublisherEvidence { + pub(crate) fn is_unknown(&self) -> bool { + matches!(self, Self::Unknown) + } +} + +/// age だけを免除する身元。アカウントは利用者の設定に閉じる。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgeExemptions { + #[serde(default)] + pub github: Vec, +} + +impl AgeExemptions { + pub fn is_empty(&self) -> bool { + self.github.is_empty() + } + + pub(crate) fn validate(&mut self) { + self.github.retain(|login| { + let valid = valid_github_login(login); + if !valid { + eprintln!("Warning: invalid GitHub login in age_exempt.github: {login:?}"); + } + valid + }); + for login in &mut self.github { + login.make_ascii_lowercase(); + } + self.github.sort(); + self.github.dedup(); + } + + /// 全 age 判定で共有する。情報が欠ける場合は通常の cutoff を維持する。 + pub(crate) fn admits( + &self, + language: Language, + package: &str, + version: &VersionInfo, + cutoff: DateTime, + ) -> bool { + version.released_at <= cutoff || self.exemption(language, package, version).is_some() + } + + pub(crate) fn exemption( + &self, + language: Language, + package: &str, + version: &VersionInfo, + ) -> Option { + let (owner, evidence) = match language { + Language::Rust => match &version.publisher { + PublisherEvidence::GithubUser { login } => (login.as_str(), "crates_io_publisher"), + PublisherEvidence::GithubTrustedPublisher { owner } => { + (owner.as_str(), "trusted_publishing") + } + PublisherEvidence::Unknown => return None, + }, + Language::Go => ( + github_owner_repo(package.strip_prefix("github.com/")?)?, + "github_source", + ), + // PackageSwiftParser は GitHub の取得元を検証して owner/repo に正規化する。 + Language::Swift => (github_owner_repo(package)?, "github_source"), + _ => return None, + }; + if !valid_github_login(owner) + || !self + .github + .iter() + .any(|login| login.eq_ignore_ascii_case(owner)) + { + return None; + } + Some(AgeExemption { + identity: format!("github:{}", owner.to_ascii_lowercase()), + evidence: evidence.to_string(), + }) + } +} + +/// 出力に使う、免除された身元と根拠。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgeExemption { + pub identity: String, + pub evidence: String, +} + +pub(crate) fn valid_github_login(login: &str) -> bool { + !login.is_empty() + && login.len() <= 39 + && !login.starts_with('-') + && !login.ends_with('-') + && !login.contains("--") + && login + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + +/// Go のサブモジュールも許可するが、所有者とリポジトリはセグメントで照合する。 +fn github_owner_repo(path: &str) -> Option<&str> { + let mut segments = path.split('/'); + let owner = segments.next()?; + let repo = segments.next()?; + if !valid_github_login(owner) + || !crate::registry::is_valid_registry_id_segment(repo) + || segments.any(|segment| !crate::registry::is_valid_registry_id_segment(segment)) + { + return None; + } + Some(owner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_configured_and_verified_identities_bypass_age() { + let exemptions = AgeExemptions { + github: vec!["example-dev".into()], + }; + let cutoff = Utc::now() - chrono::Duration::days(14); + let unknown = VersionInfo::now("1.1.0"); + assert!(!exemptions.admits(Language::Rust, "library", &unknown, cutoff)); + let mut known = unknown.clone(); + known.publisher = PublisherEvidence::GithubUser { + login: "EXAMPLE-DEV".into(), + }; + assert!(exemptions.admits(Language::Rust, "library", &known, cutoff)); + known.publisher = PublisherEvidence::GithubUser { + login: "another-dev".into(), + }; + assert!(!exemptions.admits(Language::Rust, "library", &known, cutoff)); + // 別レジストリの同名アカウントは GitHub の身元にはならない。 + assert!(!exemptions.admits(Language::Node, "@example-dev/library", &known, cutoff)); + assert!(!AgeExemptions::default().admits(Language::Rust, "library", &known, cutoff)); + let old = VersionInfo::new("1.0.0", cutoff); + assert!(exemptions.admits(Language::Rust, "library", &old, cutoff)); + } + + #[test] + fn source_identity_matches_complete_github_segments() { + let exemptions = AgeExemptions { + github: vec!["example-dev".into()], + }; + let version = VersionInfo::now("1.1.0"); + for name in [ + "github.com/example-dev/library", + "github.com/Example-Dev/library/sub/v2", + ] { + assert!( + exemptions.exemption(Language::Go, name, &version).is_some(), + "{name}" + ); + } + for name in [ + "github.com/example-dev-evil/library", + "example.com/github.com/example-dev/library", + "github.com/example-dev/../other", + "github.com/example-dev", + ] { + assert!( + exemptions.exemption(Language::Go, name, &version).is_none(), + "{name}" + ); + } + assert!( + exemptions + .exemption(Language::Swift, "example-dev/library", &version) + .is_some() + ); + assert!( + exemptions + .exemption(Language::Python, "example-dev/library", &version) + .is_none() + ); + } +} diff --git a/src/update/filter.rs b/src/update/filter.rs index 4eccda5..d59f126 100644 --- a/src/update/filter.rs +++ b/src/update/filter.rs @@ -19,6 +19,8 @@ pub struct UpdateFilter { pub include_pinned: bool, /// バージョンが考慮されるための最小経過日数 pub min_age: Option, + /// レジストリが確認した公開者に対する age の例外。 + pub age_exempt: super::AgeExemptions, /// 許容する変更レベルの上限 (例: `Patch` を指定すると patch のみ許可) pub max_change: Option, } diff --git a/src/update/mod.rs b/src/update/mod.rs index 938af22..0717033 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -5,9 +5,11 @@ //! - レジストリから取得したリリース日時付きバージョン情報 //! - 更新するかスキップするかを決める判定エンジン +pub(crate) mod age_policy; mod filter; mod version_info; +pub use age_policy::{AgeExemption, AgeExemptions, PublisherEvidence}; pub use filter::UpdateFilter; pub(crate) use version_info::{NumericIdentifier, numeric_core}; pub use version_info::{ @@ -457,7 +459,7 @@ impl UpdateJudge { let stable = self.stable_candidates(dependency, available_versions); let flavored = apply_java_flavor_filter(dependency, stable); - let age_filtered = self.apply_age_filter(flavored); + let age_filtered = self.apply_age_filter(dependency, flavored); let range_filtered = apply_range_upper_bound(dependency, age_filtered); let eligible = apply_rejected_versions(dependency, range_filtered); @@ -489,7 +491,27 @@ impl UpdateJudge { ); } - select_latest_candidate(dependency, &eligible, &allowed, self.filter.max_change) + let result = + select_latest_candidate(dependency, &eligible, &allowed, self.filter.max_change); + let exemption = match &result { + UpdateResult::Update { new_version, .. } => self + .filter + .min_age + .and_then(|age| crate::domain::cutoff_from(self.now, age)) + .and_then(|cutoff| { + allowed + .iter() + .find(|info| &info.version == new_version) + .filter(|info| info.released_at > cutoff) + }) + .and_then(|info| { + self.filter + .age_exempt + .exemption(dependency.language, &dependency.name, info) + }), + _ => None, + }; + result.with_age_exemption(exemption) } /// 既定ではプレリリースを除外する。現在版がプレリリースなら全候補を残す。 @@ -519,7 +541,11 @@ impl UpdateJudge { } /// `min_age` が設定されていれば、現在時刻から逆算したリリース時刻以前のものだけを残す。 - fn apply_age_filter<'a>(&self, candidates: Vec<&'a VersionInfo>) -> Vec<&'a VersionInfo> { + fn apply_age_filter<'a>( + &self, + dependency: &Dependency, + candidates: Vec<&'a VersionInfo>, + ) -> Vec<&'a VersionInfo> { let Some(min_age) = self.filter.min_age else { return candidates; }; @@ -531,7 +557,14 @@ impl UpdateJudge { }; candidates .into_iter() - .filter(|v| v.released_at <= min_release_time) + .filter(|v| { + self.filter.age_exempt.admits( + dependency.language, + &dependency.name, + v, + min_release_time, + ) + }) .collect() } } @@ -792,6 +825,55 @@ fn select_latest_candidate( #[cfg(test)] mod tests { + #[test] + fn verified_publisher_exempts_only_age_and_marks_selected_version() { + let now = fixed_time(); + let mut filter = UpdateFilter::new() + .with_min_age(Duration::from_secs(14 * 86400)) + .with_max_change(crate::domain::ChangeLevel::Minor); + filter.age_exempt.github = vec!["example-dev".into()]; + let judge = UpdateJudge::with_time(filter, now); + let dep = make_dependency("library", "1.0.0", Language::Rust, false); + let trusted = |version| { + let mut info = make_version_info_at(version, now - chrono::Duration::days(1)); + info.publisher = PublisherEvidence::GithubUser { + login: "example-dev".into(), + }; + info + }; + let versions = vec![ + make_version_info_at("1.0.0", now - chrono::Duration::days(30)), + trusted("1.1.0"), + trusted("2.0.0"), + trusted("1.2.0-beta.1"), + make_version_info_at("1.2.0", now - chrono::Duration::days(1)), + ]; + let result = judge.judge(&dep, &versions); + match result { + UpdateResult::Update { + new_version, + age_exemption, + .. + } => { + assert_eq!(new_version, "1.1.0"); + assert_eq!(age_exemption.unwrap().identity, "github:example-dev"); + } + other => panic!("{other:?}"), + } + let pinned = make_dependency("library", "1.0.0", Language::Rust, true); + assert!(matches!( + judge.judge(&pinned, &versions), + UpdateResult::Skip { + reason: SkipReason::Pinned, + .. + } + )); + let mut range = dep; + range.version_spec = + crate::domain::VersionSpec::new(VersionSpecKind::Range, ">=1.0.0 <1.1.0", "1.0.0"); + assert!(judge.judge(&range, &versions).is_skip()); + } + use super::*; use crate::domain::{Language, VersionSpec, VersionSpecKind}; use chrono::TimeZone; diff --git a/src/update/version_info.rs b/src/update/version_info.rs index 21d7327..e64799a 100644 --- a/src/update/version_info.rs +++ b/src/update/version_info.rs @@ -19,6 +19,9 @@ pub struct VersionInfo { pub version: String, /// このバージョンがリリースされた日時 pub released_at: DateTime, + /// レジストリが確認した公開者。自己申告の repository URL は使わない。 + #[serde(default, skip_serializing_if = "super::PublisherEvidence::is_unknown")] + pub publisher: super::PublisherEvidence, } impl VersionInfo { @@ -27,6 +30,7 @@ impl VersionInfo { Self { version: version.into(), released_at, + publisher: super::PublisherEvidence::Unknown, } } @@ -35,6 +39,7 @@ impl VersionInfo { Self { version: version.into(), released_at: Utc::now(), + publisher: super::PublisherEvidence::Unknown, } }