From de912612bb0d6fff470d4a9db15191e900e15269 Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:57:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20install=20=E5=BE=8C=E3=81=AE=20lock?= =?UTF-8?q?=20=E5=85=A8=E4=BB=B6=E3=82=92=20age=20=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=81=97=E6=9C=AA=E8=A7=A3=E6=B6=88=E3=82=92=E5=A0=B1=E5=91=8A?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 変更のない再実行で既存 lock を監査せず、Node の実際の解決版も確認していなかった。 差し戻しと全件検証を分離し、Rust と Node の違反・未確認を exit 2 で報告する。 要求した差し戻し先と実際の移動先を分け、失敗理由も通常出力に残す。 --- src/cargo_rollback/audit.rs | 281 +++++---- src/cargo_rollback/audit/helper_tests.rs | 14 + src/cargo_rollback/report.rs | 102 +-- src/install.rs | 146 ++++- src/install/tests.rs | 75 +++ src/lib.rs | 1 + src/main.rs | 91 ++- src/node_lock.rs | 718 +++++++++++++++++++++ src/node_lock/tests.rs | 762 +++++++++++++++++++++++ src/orchestrator/lock_age_tests.rs | 214 ++++++- src/package_manager.rs | 53 +- src/registry/crates_io.rs | 167 ++++- src/registry/mod.rs | 11 + src/registry/npm.rs | 58 ++ tests/e2e_tests.rs | 51 ++ 15 files changed, 2531 insertions(+), 213 deletions(-) create mode 100644 src/node_lock.rs create mode 100644 src/node_lock/tests.rs diff --git a/src/cargo_rollback/audit.rs b/src/cargo_rollback/audit.rs index dd4dc1f..431b74d 100644 --- a/src/cargo_rollback/audit.rs +++ b/src/cargo_rollback/audit.rs @@ -8,6 +8,7 @@ use crate::manifest::{RegistryLockEntries, parse_registry_entries}; use crate::registry::RegistryAdapter; use crate::registry::versions::VersionFetcher; use crate::update::{VersionInfo, compare_versions}; +use futures::{StreamExt, stream}; use indicatif::ProgressBar; use std::collections::{BTreeMap, HashMap, HashSet}; use std::ffi::OsStr; @@ -52,13 +53,9 @@ pub(crate) fn read_audit_lock(path: &Path) -> Result<(String, RegistryLockEntrie /// 反復することで連鎖を解消するが、無限ループを避けるため上限を設ける。 const MAX_ENFORCE_LOCK_AGE_PASSES: usize = 5; -/// `enforce_lock_age_rust` がレジストリ照会に使える時間の上限。 -/// -/// crates.io は crawler policy により 1 リクエスト/秒へ直列化されるため、監査対象が -/// 数百件に膨らむと数分間ネットワーク待ちで無音になり、利用者からはハングと区別が -/// つかない。通常は差分監査 (`changed_entries`) で数件に収まるが、Cargo.lock を -/// 新規生成した直後など対象が大量になるケースの安全弁として上限を設ける。 -/// 予算切れの場合は監査済みの分を適用したうえで、未検証件数を呼び出し側へ返す。 +/// lock ごとの差し戻しと最終全件検証に、それぞれ与える時間の上限。 +/// 候補取得 API の待機が全件検証やまとめ解きの枠を食い潰さないよう分ける。 +/// 全件検証は sparse index の公開日を並列取得し、未完了なら成功にしない。 pub const LOCK_AGE_AUDIT_BUDGET: Duration = Duration::from_secs(180); /// age 監査の差し戻しで起動する `cargo update -p --precise` 1 回あたりの上限。 @@ -94,7 +91,7 @@ pub struct LockAgeAuditResult { /// 期間を満たさなかった `(crate 名, 元の版)` ごとの最終結果 (名前 → 版の順)。 /// 1 件ずつの差し戻しで失敗し、後のまとめ解きで戻せた組は、戻せた結果だけが残る pub adjustments: Vec, - /// 監査を終えた時点の Cargo.lock に残る、install で変わった版のうち公開日を確かめられ + /// 監査を終えた時点の Cargo.lock に残る版のうち公開日を確かめられ /// なかった件数 (時間予算 `LOCK_AGE_AUDIT_BUDGET` 切れなど。0 なら全件を確かめた) pub unchecked: usize, /// 利用者に必ず知らせるべき問題 (まとめ解きの後に元の Cargo.lock を戻せなかった等) @@ -102,26 +99,21 @@ pub struct LockAgeAuditResult { } impl LockAgeAuditResult { - /// install による age 違反や未確認の状態が残ったか。既存版への復元は変更の取り消し。 + /// 最終 lock に age 違反や未確認の状態が残ったか。既存版も例外にしない。 pub fn has_unresolved(&self) -> bool { self.unchecked > 0 || !self.problems.is_empty() - || self.adjustments.iter().any(|adjustment| { - !adjustment.status.is_resolved() && !adjustment.status.is_restored() - }) + || self + .adjustments + .iter() + .any(|adjustment| !adjustment.status.is_resolved()) } } /// install 前の Cargo.lock (`baseline`) から見て、新規に入った / バージョンが変わった /// registry エントリだけを名前順で抽出する。 /// -/// post-install age 監査の対象を「depup の更新によって lock に入ったもの」へ限定する -/// ための絞り込み。lock 全体を監査すると crates.io の 1 リクエスト/秒 制限により -/// 数百件 = 数分の無音待ちになるうえ、depup が触っていない既存の解決結果まで -/// 差し戻し対象にしてしまう。 -/// -/// `baseline` が空の場合 (install 前に Cargo.lock が無かった等) は全エントリが -/// 「新規に入ったもの」なので、結果的に全件が対象になる。 +/// 全件監査の順序で、新しく解決された crate を優先するために使う。 fn changed_entries( current: &RegistryLockEntries, baseline: &RegistryLockEntries, @@ -143,6 +135,23 @@ fn changed_entries( targets } +/// 変更版を先に確認するが、無変更の版も監査対象から外さない。 +fn audit_entries( + current: &RegistryLockEntries, + baseline: &RegistryLockEntries, +) -> Vec<(String, Vec)> { + let changed: HashSet = changed_entries(current, baseline) + .into_iter() + .map(|(name, _)| name) + .collect(); + let mut targets: Vec<_> = current + .iter() + .map(|(name, versions)| (name.clone(), versions.clone())) + .collect(); + targets.sort_by(|a, b| (!changed.contains(&a.0), &a.0).cmp(&(!changed.contains(&b.0), &b.0))); + targets +} + /// `enforce_lock_age_rust` が 1 件の依存に対して実施した調整内容 #[derive(Debug, Clone, PartialEq, Eq)] pub struct LockAgeAdjustment { @@ -150,8 +159,10 @@ pub struct LockAgeAdjustment { pub name: String, /// Cargo.lock 上で解決されていたバージョン pub from: String, - /// 差し戻したバージョン (`Downgraded` のみ) + /// 最終 lock 上で実際に移動した版。 pub to: Option, + /// 差し戻しで要求した版。実際の移動先とは分ける。 + pub target: Option, /// 差し戻しの結果 pub status: LockAgeStatus, } @@ -204,7 +215,7 @@ pub(crate) struct LockAgeAudit<'a> { /// これより後に公開された版を違反とする pub(crate) cutoff: chrono::DateTime, pub(crate) exemptions: &'a crate::update::AgeExemptions, - /// install 前の Cargo.lock (これと同じ版は監査しない) + /// install 前の Cargo.lock (差し戻しの下限と確認順序に使う) pub(crate) baseline: &'a RegistryLockEntries, /// judge がこの実行で選んだ版 pub(crate) preferred: &'a PreferredVersions, @@ -228,12 +239,18 @@ struct AdjustmentLog { impl AdjustmentLog { /// 結果を記録する (既にあれば上書き) fn record(&mut self, name: &str, from: &str, to: Option, status: LockAgeStatus) { + let (to, target) = if status.is_resolved() || status.is_restored() { + (to.clone(), to) + } else { + (None, to) + }; self.entries.insert( (name.to_string(), from.to_string()), LockAgeAdjustment { name: name.to_string(), from: from.to_string(), to, + target, status, }, ); @@ -247,14 +264,9 @@ impl AdjustmentLog { to: Option, status: LockAgeStatus, ) { - self.entries - .entry((name.to_string(), from.to_string())) - .or_insert_with(|| LockAgeAdjustment { - name: name.to_string(), - from: from.to_string(), - to, - status, - }); + if !self.contains(name, from) { + self.record(name, from, to, status); + } } /// その組の結果が既にあるか @@ -361,7 +373,7 @@ pub(crate) fn rollback_target( } } -/// install 前の lock (`before`) にあった、`current` と同じ semver 系列で `current` より古い版の +/// install 前の lock (`before`) にあった、`current` と同じ semver 系列で `current` 以下の版の /// うち利用可能な最新。yank 済み・公開日不明などで取得一覧にない版は、下限にも戻し先にも使わない。 fn version_floor( available: &[VersionInfo], @@ -372,7 +384,7 @@ fn version_floor( .unwrap_or_default() .iter() .filter(|version| same_series(version, current)) - .filter(|version| compare_versions(version, current) == std::cmp::Ordering::Less) + .filter(|version| compare_versions(version, current) != std::cmp::Ordering::Greater) .filter(|version| { available .iter() @@ -582,7 +594,7 @@ impl<'a> LockAgeAuditor<'a> { break; } } - self.finalize(state).await + self.finalize(state, bar).await } /// 一巡分の公開日検査と、単独で差し戻せる候補の適用。 @@ -605,9 +617,6 @@ impl<'a> LockAgeAuditor<'a> { } = *self.input; let lock_path = project_dir.join("Cargo.lock"); let (_, entries) = read_audit_lock(&lock_path)?; - // install 前と同じバージョンで lock されていた依存は depup の更新で入った - // ものではないため監査しない。crates.io は 1 リクエスト/秒に直列化される - // ので、lock 全体 (数百件) を舐めると監査だけで数分の無音待ちになる。 let targets = changed_entries(&entries, baseline); if targets.is_empty() { return Ok(AuditPass::default()); @@ -624,7 +633,7 @@ impl<'a> LockAgeAuditor<'a> { } if pass_index == 0 && self.verbose { let message = format!( - " {} — {} crate(s) changed by install; checking release dates", + " {} — {} changed registry crate(s) in Cargo.lock; checking release dates", project_dir.display(), targets.len() ); @@ -722,6 +731,17 @@ impl<'a> LockAgeAuditor<'a> { .record(name, current, None, LockAgeStatus::NoOlderCandidate); continue; }; + if compare_versions(&target, current) != std::cmp::Ordering::Less { + state.log.record_if_absent( + name, + current, + pick_older_within_age(&all_versions, current, cutoff, exemptions), + LockAgeStatus::NotAttempted(format!( + "the pre-install locked version {target} is the rollback minimum and does not satisfy --age" + )), + ); + continue; + } let candidate = RollbackCandidate { name: name.clone(), current: current.clone(), @@ -739,7 +759,7 @@ impl<'a> LockAgeAuditor<'a> { state.log.record_if_absent( name, current, - None, + Some(target), LockAgeStatus::NotAttempted(format!( "stopped after {MAX_ENFORCE_LOCK_AGE_PASSES} rounds of rollbacks" )), @@ -845,6 +865,7 @@ impl<'a> LockAgeAuditor<'a> { LockAgeStatus::Removed }; state.log.record(name, current, moved, status); + state.log.entries.get_mut(&key).unwrap().target = Some(target.clone()); return Ok(()); } None => LockAgeStatus::UpdateCommandFailed(format!( @@ -861,11 +882,11 @@ impl<'a> LockAgeAuditor<'a> { state.log.record( name, current, - None, + Some(target.clone()), LockAgeStatus::UpdateCommandFailed(message), ); } - other => state.log.record(name, current, None, other), + other => state.log.record(name, current, Some(target.clone()), other), } Ok(()) } @@ -893,7 +914,7 @@ impl<'a> LockAgeAuditor<'a> { state.log.record_if_absent( &candidate.name, &candidate.current, - None, + Some(candidate.target.clone()), LockAgeStatus::NotAttempted( "audit time budget ran out before resolving together".to_string(), ), @@ -929,6 +950,8 @@ impl<'a> LockAgeAuditor<'a> { Some(version), LockAgeStatus::Downgraded, ); + state.log.entries.get_mut(&key).unwrap().target = + Some(candidate.target.clone()); } TogetherOutcome::Removed => { resolved_any = true; @@ -945,7 +968,7 @@ impl<'a> LockAgeAuditor<'a> { state.log.record( &candidate.name, &candidate.current, - None, + Some(candidate.target.clone()), LockAgeStatus::BlockedByManifest(requirement), ); } @@ -959,7 +982,7 @@ impl<'a> LockAgeAuditor<'a> { state.log.record( &candidate.name, &candidate.current, - None, + Some(candidate.target.clone()), LockAgeStatus::UpdateCommandFailed(message), ); } @@ -972,9 +995,12 @@ impl<'a> LockAgeAuditor<'a> { )), None => LockAgeStatus::NotAttempted(reason), }; - state - .log - .record(&candidate.name, &candidate.current, None, status); + state.log.record( + &candidate.name, + &candidate.current, + Some(candidate.target.clone()), + status, + ); } } } @@ -990,7 +1016,7 @@ impl<'a> LockAgeAuditor<'a> { state.log.record_if_absent( &candidate.name, &candidate.current, - None, + Some(candidate.target.clone()), LockAgeStatus::NotAttempted( "resolving together already failed for the same Cargo.lock".to_string(), ), @@ -1000,14 +1026,15 @@ impl<'a> LockAgeAuditor<'a> { Ok(()) } - /// 通信せず、取得済みの公開日と最終 lock を突き合わせる。 - async fn finalize(&self, state: AuditState) -> LockAgeAuditResult { + /// 差し戻しとは別の時間予算で、最終 lock の全件を検証する。 + async fn finalize(&self, state: AuditState, bar: Option<&ProgressBar>) -> LockAgeAuditResult { let LockAgeAudit { project_dir, cutoff, exemptions, baseline, adapter, + budget, .. } = *self.input; let lock_path = project_dir.join("Cargo.lock"); @@ -1016,11 +1043,8 @@ impl<'a> LockAgeAuditor<'a> { mut problems, .. } = state; - // 報告は最終的な Cargo.lock の状態から組み立て直す。途中の記録だけで「差し戻した」と - // 言うと、後の差し戻しで版が動いた・まとめ解きの結果を確かめる前に予算が尽きた等の - // 場合に、lock の実態と食い違う - let final_entries = match read_audit_lock(&lock_path) { - Ok((_, entries)) => entries, + let (content, final_entries) = match read_audit_lock(&lock_path) { + Ok(entries) => entries, Err(error) => { if !problems.contains(&error) { problems.push(error); @@ -1032,79 +1056,110 @@ impl<'a> LockAgeAuditor<'a> { } }; log.reconcile_with_lock(&final_entries); - // 途中で記録した移動先ではなく、最終 lock の公開日を確認する。 - let moved: Vec<(String, String, String)> = log - .entries - .values() - .filter(|adjustment| adjustment.status == LockAgeStatus::Downgraded) - .filter_map(|adjustment| { - Some(( - adjustment.name.clone(), - adjustment.from.clone(), - adjustment.to.clone()?, - )) - }) - .collect(); - for (name, from, to) in moved { - let known = self.versions.cached(adapter, &name).await; - let info = known.as_ref().and_then(|all| { - all.iter() - .find(|info| compare_versions(&info.version, &to) == std::cmp::Ordering::Equal) - }); - let status = match info { - None => Some(LockAgeStatus::ReleaseDateUnavailable), - Some(info) if exemptions.admits(Language::Rust, &name, info, cutoff) => None, + let targets = audit_entries(&final_entries, baseline); + let deadline = tokio::time::Instant::now() + budget; + if let Some(bar) = bar { + bar.set_position(0); + bar.set_length(targets.len() as u64); + bar.set_message("Verifying all registry crates in final Cargo.lock"); + } + let mut metadata = HashMap::new(); + // 実行内の候補キャッシュは使えるが、候補から落ちた yank 済みの版は別途取得する。 + { + let requests = stream::iter(targets.iter().map(|(name, locked)| async move { + let cached = self.versions.cached(adapter, name).await; + if let Some(cached) = cached + && locked + .iter() + .all(|locked| cached.iter().any(|info| info.version == *locked)) + { + return (name.clone(), Some(cached)); + } + let fetched = adapter + .fetch_locked_versions(name, locked, (!exemptions.is_empty()).then_some(cutoff)) + .await + .ok(); + (name.clone(), fetched) + })) + .buffer_unordered(8); + tokio::pin!(requests); + while tokio::time::Instant::now() < deadline { + let Ok(Some((name, known))) = + tokio::time::timeout_at(deadline, requests.next()).await + else { + break; + }; + metadata.insert(name.clone(), known); + if let Some(bar) = bar { + bar.set_position(metadata.len() as u64); + bar.set_message(format!("Verifying {name}")); + } + } + } + // 実際の移動先を最終状態で判定する。既存の若い版へ戻しても成功にはしない。 + for adjustment in log.entries.values_mut() { + if adjustment.status != LockAgeStatus::Downgraded { + continue; + } + let Some(to) = &adjustment.to else { + continue; + }; + let info = metadata + .get(&adjustment.name) + .and_then(Option::as_ref) + .and_then(|known| known.iter().find(|info| info.version == *to)); + adjustment.status = match info { + Some(info) if exemptions.admits(Language::Rust, &adjustment.name, info, cutoff) => { + LockAgeStatus::Downgraded + } Some(_) if baseline - .get(&name) - .is_some_and(|versions| versions.contains(&to)) => + .get(&adjustment.name) + .is_some_and(|versions| versions.contains(to)) => { - Some(LockAgeStatus::Restored) + LockAgeStatus::Restored } - Some(_) => Some(LockAgeStatus::NotAttempted(format!( + Some(_) => LockAgeStatus::NotAttempted(format!( "final locked version {to} does not satisfy --age" - ))), + )), + None => LockAgeStatus::ReleaseDateUnavailable, }; - if let Some(status) = status { - log.record(&name, &from, Some(to), status); - } } - let mut unchecked = 0usize; - for (name, versions) in changed_entries(&final_entries, baseline) { - // ここではレジストリへ問い合わせない (予算を使い切っていることがある)。 - // 監査中に取得した公開日だけで判定し、取得していないものは未検証として数える - let known = self.versions.cached(adapter, &name).await; + let mut unchecked = 0; + for (name, versions) in targets { + let known = metadata.get(&name).and_then(Option::as_ref); for version in versions { - if log.contains(&name, &version) { + let info = + known.and_then(|known| known.iter().find(|info| info.version == version)); + if info.is_some_and(|info| exemptions.admits(Language::Rust, &name, info, cutoff)) { + // 最終状態を確認できれば途中の取得失敗は残さない。 + log.entries.remove(&(name.clone(), version.clone())); continue; } - let info = known.as_ref().and_then(|all| { - all.iter().find(|info| { - compare_versions(&info.version, &version) == std::cmp::Ordering::Equal - }) - }); - match (known.as_ref(), info) { - (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(), - ), - ) - } - (Some(_), Some(_)) => {} - (Some(_), None) => { - log.record(&name, &version, None, LockAgeStatus::ReleaseDateUnavailable) - } - (None, _) => unchecked += 1, + // 差し戻し先の若い版を別件で重複報告しない。 + if log.entries.values().any(|adjustment| { + adjustment.name == name && adjustment.to.as_ref() == Some(&version) + }) { + continue; + } + if !metadata.contains_key(&name) { + unchecked += 1; } + let status = match info { + None => LockAgeStatus::ReleaseDateUnavailable, + Some(_) if baseline.get(&name).is_some_and(|versions| versions.contains(&version)) => LockAgeStatus::NotAttempted( + "the pre-install locked version is the rollback minimum and does not satisfy --age".into() + ), + Some(_) => LockAgeStatus::NotAttempted("the audit ended before rolling it back".into()), + }; + log.record_if_absent(&name, &version, None, status); } } - + if std::fs::read_to_string(&lock_path) + .map_or(true, |final_content| final_content != content) + { + problems.push("Cargo.lock changed during final age verification; rerun --install to verify the new state".into()); + } LockAgeAuditResult { adjustments: log.into_adjustments(), unchecked, diff --git a/src/cargo_rollback/audit/helper_tests.rs b/src/cargo_rollback/audit/helper_tests.rs index 91c5126..fd1e96a 100644 --- a/src/cargo_rollback/audit/helper_tests.rs +++ b/src/cargo_rollback/audit/helper_tests.rs @@ -113,6 +113,19 @@ fn test_changed_entries_no_changes_is_empty() { assert!(changed_entries(¤t, &baseline).is_empty()); } +#[test] +fn full_audit_includes_unchanged_versions_after_changed_crates() { + let baseline = lock_entries(&[("alpha", &["1.0.0"]), ("zeta", &["1.0.0"])]); + let current = lock_entries(&[("alpha", &["1.0.0"]), ("zeta", &["1.0.0", "1.0.1"])]); + assert_eq!( + audit_entries(¤t, &baseline), + vec![ + ("zeta".into(), vec!["1.0.0".into(), "1.0.1".into()]), + ("alpha".into(), vec!["1.0.0".into()]), + ] + ); +} + #[test] fn test_pick_older_within_age_basic() { // age cutoff = 14 日前。現在 lock = 1.5.0 (3 日前) は age 違反。 @@ -358,6 +371,7 @@ fn test_adjustment_log_reconciles_with_final_lock() { LockAgeStatus::UpdateCommandFailed(ref m) if m.contains("still has still 1.0.2") )); assert_eq!(by_name("still").to, None); + assert_eq!(by_name("still").target.as_deref(), Some("1.0.1")); assert_eq!(by_name("moved").status, LockAgeStatus::Downgraded); assert_eq!(by_name("moved").to.as_deref(), Some("1.0.0")); assert_eq!(by_name("gone").status, LockAgeStatus::Removed); diff --git a/src/cargo_rollback/report.rs b/src/cargo_rollback/report.rs index 9322880..b5da7da 100644 --- a/src/cargo_rollback/report.rs +++ b/src/cargo_rollback/report.rs @@ -91,7 +91,7 @@ impl ReportLine { /// 1 つの Cargo.lock の監査結果を、stderr に出す行へ変換する。 /// -/// 差し戻せなかった crate の件数は `--verbose` が無くても必ず出す。`--age` は供給網 +/// 差し戻せなかった crate の名前・要求版・理由は `--verbose` が無くても必ず出す。`--age` は供給網 /// 対策として使われるので、満たせなかったことを黙って捨てると、差し戻せた分だけが /// 表示されて `--age` が効いたように見えてしまう。 pub fn lock_age_report_lines( @@ -120,7 +120,7 @@ pub fn lock_age_report_lines( // (直前に未検証件数を警告済み) if verbose && audit.unchecked == 0 && audit.problems.is_empty() { lines.push(ReportLine::info(format!( - " {dir} — all crates changed by install are within --age" + " {dir} — all registry crates in Cargo.lock are within --age" ))); } return lines; @@ -182,45 +182,37 @@ pub fn lock_age_report_lines( } if !remaining.is_empty() { - if verbose { - lines.push(ReportLine::warning(format!( - " {dir} — {} crate(s) could not be rolled back to satisfy --age:", - remaining.len() - ))); - for adj in &remaining { - lines.push(ReportLine::warning(format!( - " {} ({}): {}", - adj.name, - adj.from, - lock_age_status_detail(&adj.status) - ))); - } - } else { + lines.push(ReportLine::warning(format!( + " {dir} — {} crate(s) could not be rolled back to satisfy --age:", + remaining.len() + ))); + for adj in &remaining { + let requested = adj + .target + .as_ref() + .map(|version| format!("; requested: {version}")) + .unwrap_or_default(); lines.push(ReportLine::warning(format!( - " {dir} — {} crate(s) could not be rolled back to satisfy --age (use --verbose for details)", - remaining.len() + " {} ({}{}): {}", + adj.name, + adj.from, + requested, + lock_age_status_detail(&adj.status) ))); } } if !unverified.is_empty() { - if verbose { - lines.push(ReportLine::warning(format!( - " {dir} — {} crate(s) could not be checked against --age:", - unverified.len() - ))); - for adj in &unverified { - lines.push(ReportLine::warning(format!( - " {} ({}): {}", - adj.name, - adj.from, - lock_age_status_detail(&adj.status) - ))); - } - } else { + lines.push(ReportLine::warning(format!( + " {dir} — {} crate(s) could not be checked against --age:", + unverified.len() + ))); + for adj in &unverified { lines.push(ReportLine::warning(format!( - " {dir} — {} crate(s) could not be checked against --age: release date unavailable (use --verbose for details)", - unverified.len() + " {} ({}): {}", + adj.name, + adj.from, + lock_age_status_detail(&adj.status) ))); } } @@ -365,7 +357,16 @@ mod tests { LockAgeAdjustment { name: name.to_string(), from: from.to_string(), - to: to.map(str::to_string), + to: if status.is_resolved() || status.is_restored() { + to.map(str::to_string) + } else { + None + }, + target: if status.is_resolved() || status.is_restored() { + None + } else { + to.map(str::to_string) + }, status, } } @@ -374,7 +375,27 @@ mod tests { lines.iter().map(|line| line.text.as_str()).collect() } - /// 差し戻せなかった crate の件数は `--verbose` が無くても 1 行出す。 + #[test] + fn failure_details_include_requested_version_without_verbose() { + let audit = LockAgeAuditResult { + adjustments: vec![adjustment( + "example-udp", + "0.5.16", + Some("0.5.15"), + LockAgeStatus::UpdateCommandFailed("resolver conflict".into()), + )], + ..Default::default() + }; + let lines = lock_age_report_lines(Path::new("."), &audit, false); + assert!(lines.iter().any(|line| { + line.text.contains("example-udp") + && line.text.contains("0.5.16") + && line.text.contains("requested: 0.5.15") + && line.text.contains("resolver conflict") + })); + } + + /// 差し戻せなかった crate の詳細は `--verbose` が無くても出す。 /// 見出しは直接依存も含むので `transitive dep(s)` ではなく `crate(s)` #[test] fn test_lock_age_report_lines_counts_failures_without_verbose() { @@ -400,7 +421,9 @@ mod tests { vec![ " ./wasm — 1 crate(s) rolled back to satisfy --age:", " cc 1.5.1 → 1.4.6", - " ./wasm — 2 crate(s) could not be rolled back to satisfy --age (use --verbose for details)", + " ./wasm — 2 crate(s) could not be rolled back to satisfy --age:", + " wasm-bindgen (0.2.129): cargo update failed: conflict", + " web-sys (0.3.106): no older version satisfies --age", ] ); assert!(!lines[0].warning); @@ -481,7 +504,8 @@ mod tests { texts(&lines), vec![ " . — age audit stopped after 180s; 3 crate(s) left unchecked", - " . — 1 crate(s) could not be checked against --age: release date unavailable (use --verbose for details)", + " . — 1 crate(s) could not be checked against --age:", + " private-crate (0.1.0): release date unavailable", ] ); assert!(lines.iter().all(|line| line.warning)); @@ -494,7 +518,7 @@ mod tests { assert!(lock_age_report_lines(Path::new("."), &audit, false).is_empty()); assert_eq!( texts(&lock_age_report_lines(Path::new("."), &audit, true)), - vec![" . — all crates changed by install are within --age"] + vec![" . — all registry crates in Cargo.lock are within --age"] ); } diff --git a/src/install.rs b/src/install.rs index e54fbbf..077d2fc 100644 --- a/src/install.rs +++ b/src/install.rs @@ -2,7 +2,7 @@ //! //! install 前の lock を保存し、install 後に生成された lock も含めて監査を計画する。 -use crate::cargo_rollback::audit::PreferredVersions; +use crate::cargo_rollback::audit::{PreferredVersions, read_audit_lock}; use crate::cargo_rollback::report::{DisplayedUpdate, LockedVersionMismatch, lock_mismatches}; use crate::domain::{Language, UpdateResult}; use crate::manifest::{ @@ -24,7 +24,9 @@ pub struct InstallJob { /// install 前に確定する実行単位と、変更を判定するための lock の保存。 pub struct InstallPlan { pub jobs: Vec, - baselines: BTreeMap, + /// Node lock の監査は、マニフェストの更新がない実行でも計画する。 + pub node_audits: Vec, + baselines: BTreeMap>, } impl InstallPlan { @@ -34,11 +36,22 @@ impl InstallPlan { monorepo_dirs: &Option>, default_path: &Path, ) -> Self { + let node_audits = node_audit_jobs(orchestrator, result); + let shared_node_policies = &node_audits; let jobs = build_install_map(result, monorepo_dirs, default_path) .into_iter() .flat_map(|(directory, languages)| { languages.into_iter().map(move |language| InstallJob { - policy: install_age_policy(orchestrator, result, &directory, language), + policy: if language == Language::Node { + shared_node_policies + .iter() + .find(|job| job.directory == directory) + .expect("every Node install has an audit scope") + .policy + .clone() + } else { + install_age_policy(orchestrator, result, &directory, language) + }, directory: directory.clone(), language, }) @@ -47,14 +60,18 @@ impl InstallPlan { let groups = RustLockGroups::collect(orchestrator, result); let baselines = groups .locks - .into_iter() - .filter(|(_, group)| !group.manifests.is_empty()) - .map(|(directory, _)| { - let entries = read_registry_entries(&directory.join("Cargo.lock")); + .into_keys() + .map(|directory| { + let entries = + read_audit_lock(&directory.join("Cargo.lock")).map(|(_, entries)| entries); (directory, entries) }) .collect(); - Self { jobs, baselines } + Self { + jobs, + node_audits, + baselines, + } } /// install が新規作成した lock を再検出し、install 前の版を対応付ける。 @@ -67,9 +84,18 @@ impl InstallPlan { let locks = groups .locks .into_iter() - .filter(|(_, group)| !group.manifests.is_empty()) .map(|(directory, group)| RustLockPlan { - baseline: self.baselines.get(&directory).cloned().unwrap_or_default(), + baseline: self + .baselines + .get(&directory) + .and_then(|result| result.as_ref().ok()) + .cloned() + .unwrap_or_default(), + baseline_problem: self + .baselines + .get(&directory) + .and_then(|result| result.as_ref().err()) + .cloned(), directory, policy: group.policy, preferred: group.preferred, @@ -78,7 +104,15 @@ impl InstallPlan { .collect(); RustAuditPlan { locks, - missing_lock_dirs: groups.missing_lock_dirs, + missing_lock_dirs: groups + .missing_lock_dirs + .into_iter() + .filter(|directory| { + self.jobs.iter().any(|job| { + job.language == Language::Rust && directory.starts_with(&job.directory) + }) + }) + .collect(), } } } @@ -88,6 +122,8 @@ pub struct RustLockPlan { pub directory: PathBuf, pub policy: AgePolicy, pub baseline: RegistryLockEntries, + /// 既存 lock を読めなかった場合、空の下限で自動差し戻ししない。 + pub baseline_problem: Option, pub preferred: PreferredVersions, manifests: Vec, } @@ -177,7 +213,7 @@ impl RustLockGroups { } let mut missing_lock_dirs = BTreeSet::new(); for (index, manifest) in result.summary.manifests.iter().enumerate() { - if manifest.language != Language::Rust || !manifest.has_updates() { + if manifest.language != Language::Rust { continue; } let Some(parent) = manifest.path.parent() else { @@ -193,6 +229,9 @@ impl RustLockGroups { } continue; }; + if !manifest.has_updates() { + continue; + } let group = locks .get_mut(&directory) .expect("every detected lock has a policy"); @@ -270,6 +309,79 @@ fn nearest_monorepo_dir( .unwrap_or_else(|| fallback.to_path_buf()) } +/// pnpm の共有 lock は、実際に検出される workspace member だけを結び付ける。 +fn node_resolution_directory(manifest: &Path) -> PathBuf { + let parent = manifest.parent().unwrap_or(Path::new(".")); + let parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()); + let manifest = parent.join("package.json"); + for directory in parent.ancestors() { + if directory == parent { + if [ + "pnpm-lock.yaml", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "bun.lock", + "bun.lockb", + ] + .iter() + .any(|filename| directory.join(filename).exists()) + { + return directory.to_path_buf(); + } + } else if directory.join("pnpm-workspace.yaml").is_file() + && detect_manifests(directory).iter().any(|candidate| { + candidate.language == Language::Node + && std::fs::canonicalize(&candidate.path).is_ok_and(|path| path == manifest) + }) + { + return directory.to_path_buf(); + } + } + parent +} + +fn node_audit_jobs(orchestrator: &Orchestrator, result: &OrchestratorResult) -> Vec { + let mut groups: BTreeMap> = BTreeMap::new(); + for manifest in &result.summary.manifests { + if manifest.language == Language::Node { + groups + .entry(node_resolution_directory(&manifest.path)) + .or_default() + .insert(manifest.path.clone()); + } + } + groups + .into_iter() + .map(|(directory, mut manifests)| { + manifests.extend( + detect_manifests(&directory) + .into_iter() + .filter(|manifest| manifest.language == Language::Node) + .map(|manifest| manifest.path), + ); + let policy = (|| { + let mut policy = + orchestrator.resolved_age_policy_for_language(&directory, Language::Node)?; + for manifest in manifests { + if let Some(parent) = manifest.parent() { + policy.merge( + &orchestrator + .resolved_age_policy_for_language(parent, Language::Node)?, + ); + } + } + Ok(policy) + })(); + InstallJob { + directory, + language: Language::Node, + policy, + } + }) + .collect() +} + fn build_install_map( result: &OrchestratorResult, monorepo_dirs: &Option>, @@ -280,9 +392,13 @@ fn build_install_map( if !manifest.has_updates() { continue; } - let directory = match monorepo_dirs { - Some(dirs) => nearest_monorepo_dir(&manifest.path, dirs, default_path), - None => default_path.to_path_buf(), + let directory = if manifest.language == Language::Node { + node_resolution_directory(&manifest.path) + } else { + match monorepo_dirs { + Some(dirs) => nearest_monorepo_dir(&manifest.path, dirs, default_path), + None => default_path.to_path_buf(), + } }; let languages = dir_langs.entry(directory).or_default(); if !languages.contains(&manifest.language) { diff --git a/src/install/tests.rs b/src/install/tests.rs index 7659867..e3ab90c 100644 --- a/src/install/tests.rs +++ b/src/install/tests.rs @@ -5,6 +5,81 @@ use crate::domain::{ }; use clap::Parser; +#[test] +fn no_manifest_updates_still_plan_an_existing_rust_lock_audit() { + let temp = tempfile::tempdir().unwrap(); + write_lock(temp.path(), "1.0.1"); + let mut args = CliArgs::parse_from(["depup", "--age", "2w"]); + args.path = temp.path().to_path_buf(); + let orchestrator = Orchestrator::new(args).unwrap(); + let mut summary = UpdateSummary::new(false); + summary.add_manifest(ManifestUpdateResult::new( + temp.path().join("Cargo.toml"), + Language::Rust, + )); + let result = OrchestratorResult { + summary, + write_results: Vec::new(), + errors: Vec::new(), + }; + let plan = InstallPlan::new(&orchestrator, &result, &None, temp.path()); + let audit = plan.rust_audit_plan(&orchestrator, &result); + assert_eq!(audit.locks.len(), 1); + assert_eq!(audit.locks[0].baseline["pkg"], vec!["1.0.1"]); +} + +#[test] +fn invalid_baseline_is_not_treated_as_a_new_lock() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("Cargo.lock"), "broken {{").unwrap(); + let mut args = CliArgs::parse_from(["depup", "--age", "2w"]); + args.path = temp.path().to_path_buf(); + let orchestrator = Orchestrator::new(args).unwrap(); + let result = result_with_update(temp.path().join("Cargo.toml"), Language::Rust); + let plan = InstallPlan::new(&orchestrator, &result, &None, temp.path()); + write_lock(temp.path(), "1.0.1"); + let audit = plan.rust_audit_plan(&orchestrator, &result); + assert!(audit.locks[0].baseline_problem.is_some()); +} + +#[test] +fn unchanged_node_workspace_uses_all_members_strictest_age() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let member = root.join("apps/web"); + let other = root.join("apps/worker"); + for directory in [root, member.as_path(), other.as_path()] { + std::fs::create_dir_all(directory).unwrap(); + std::fs::write(directory.join("package.json"), "{\"name\":\"example\"}").unwrap(); + } + std::fs::write( + root.join("pnpm-workspace.yaml"), + "packages:\n - 'apps/*'\n", + ) + .unwrap(); + std::fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n").unwrap(); + std::fs::write(other.join(".npmrc"), "minimum-release-age=30d\n").unwrap(); + let mut args = CliArgs::parse_from(["depup", "--no-age"]); + args.path = member.clone(); + let orchestrator = Orchestrator::new(args).unwrap(); + let mut result = result_with_update(member.join("package.json"), Language::Node); + let plan = InstallPlan::new(&orchestrator, &result, &None, &member); + assert_eq!(plan.jobs.len(), 1); + assert_eq!(plan.jobs[0].directory, std::fs::canonicalize(root).unwrap()); + assert_eq!( + plan.jobs[0].policy.as_ref().unwrap().min_age, + Some(std::time::Duration::from_secs(30 * 86400)) + ); + result.summary.manifests[0].results.clear(); + let plan = InstallPlan::new(&orchestrator, &result, &None, &member); + assert!(plan.jobs.is_empty()); + assert_eq!(plan.node_audits.len(), 1); + assert_eq!( + plan.node_audits[0].policy.as_ref().unwrap().min_age, + Some(std::time::Duration::from_secs(30 * 86400)) + ); +} + #[test] fn shared_install_includes_a_member_with_no_updates_and_does_not_mix_siblings() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index a4df18e..879e413 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod error; pub mod global_config; pub mod install; pub mod manifest; +pub mod node_lock; pub mod orchestrator; pub mod osv; pub mod output; diff --git a/src/main.rs b/src/main.rs index 8eb0582..4049d12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,8 +14,10 @@ use colored::Colorize; use depup::cargo_rollback::report::{lock_age_report_lines, lock_mismatch_lines}; use depup::cli::CliArgs; use depup::config::DepupConfig; +use depup::domain::Language; use depup::global_config::{GlobalConfig, resolve_max_change, resolve_osv}; use depup::install::{InstallPlan, RustAuditPlan}; +use depup::node_lock::audit_node_lock; use depup::orchestrator::Orchestrator; use depup::output::{OutputConfig, create_formatter}; use depup::package_manager::SystemPackageManager; @@ -133,10 +135,11 @@ async fn run(args: CliArgs) -> anyhow::Result { // dry-run でない場合、要求があればパッケージマネージャの install を実行 if args.install && !args.dry_run { let plan = InstallPlan::new(&orchestrator, &result, &monorepo_dirs, &args.path); - // 他の言語の install が失敗しても、変更された Rust lock の監査は行う。 + // 他の言語の install が失敗しても、Rust / Node lock の監査は行う。 let install_result = run_package_installs(&args, &plan); let audit_plan = plan.rust_audit_plan(&orchestrator, &result); age_audit_failed = enforce_rust_lock_age(&args, &orchestrator, &audit_plan).await; + age_audit_failed |= enforce_node_lock_age(&args, &plan).await; // 画面に出した更新先と、実際に Cargo.lock へ入った版の食い違いを知らせる。 // 結果の一覧は install 前に出しているので、ここで別に注記する @@ -180,7 +183,9 @@ fn run_package_installs(args: &CliArgs, plan: &InstallPlan) -> anyhow::Result<() .filter_map(|job| { let (_, pm) = pm_runner.resolve_package_manager(job.language, &job.directory)?; - (!job.language.pm_has_native_transitive_age_support(pm)).then(|| pm.to_string()) + (job.language != depup::domain::Language::Node + && !job.language.pm_has_native_transitive_age_support(pm)) + .then(|| pm.to_string()) }) .collect(); unsupported.sort(); @@ -238,7 +243,7 @@ fn run_package_installs(args: &CliArgs, plan: &InstallPlan) -> anyhow::Result<() Ok(()) } -/// 計画された共有 lock ごとに、install で変わった crate の age を監査する。 +/// 計画された共有 lock ごとに、既存版も含め crate の age を監査する。 async fn enforce_rust_lock_age( args: &CliArgs, orchestrator: &Orchestrator, @@ -256,7 +261,7 @@ async fn enforce_rust_lock_age( } if args.verbose { eprintln!(); - eprintln!("Enforcing --age on crates changed in Cargo.lock..."); + eprintln!("Enforcing --age on registry crates in Cargo.lock..."); } let mut progress = Progress::new(!args.quiet); progress.start(0, "Auditing Cargo.lock against --age"); @@ -265,6 +270,16 @@ async fn enforce_rust_lock_age( if lock.policy.min_age.is_none() { continue; } + if let Some(problem) = &lock.baseline_problem { + has_unresolved = true; + progress.suspend(|| { + eprintln!( + " {} — cannot safely roll back --age violations: pre-install {problem}", + lock.directory.display() + ) + }); + continue; + } let audit = orchestrator .enforce_lock_age_rust_with_policy( &lock.directory, @@ -292,6 +307,74 @@ async fn enforce_rust_lock_age( has_unresolved } +/// Node は lock の実際の解決版を検査し、違反・未確認を必ず報告する。 +async fn enforce_node_lock_age(args: &CliArgs, plan: &InstallPlan) -> bool { + let pm_runner = SystemPackageManager::new(); + let mut failed = false; + let mut progress = Progress::new(!args.quiet); + for job in &plan.node_audits { + let has_lock = [ + "pnpm-lock.yaml", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "bun.lock", + "bun.lockb", + ] + .iter() + .any(|filename| job.directory.join(filename).exists()); + if !has_lock + && !plan.jobs.iter().any(|install| { + install.language == Language::Node && install.directory == job.directory + }) + { + continue; + } + let policy = match &job.policy { + Ok(policy) if policy.min_age.is_some() => policy, + Ok(_) => continue, + Err(error) => { + eprintln!( + " {} — cannot audit --age: {error}", + job.directory.display() + ); + failed = true; + continue; + } + }; + let Some((directory, pm)) = pm_runner.resolve_package_manager(job.language, &job.directory) + else { + eprintln!( + " {} — cannot audit --age: Node package manager could not be determined", + job.directory.display() + ); + failed = true; + continue; + }; + progress.start(0, "Auditing Node lockfile against --age"); + let bar = progress.bar(); + let audit = audit_node_lock(&directory, pm, policy, bar.as_ref()).await; + failed |= audit.has_failures(); + progress.suspend(|| { + for message in audit.failure_messages() { + eprintln!( + "{}", + format!(" {} — {message}", directory.display()).yellow() + ); + } + if args.verbose && !audit.has_failures() { + eprintln!( + " {} — {} public npm package version(s) verified against --age", + directory.display(), + audit.checked + ); + } + }); + progress.finish_and_clear(); + } + failed +} + /// 画面に出した更新先と、監査後の lock に入った版の相違を知らせる。 fn report_rust_lock_mismatches(plan: &RustAuditPlan) { for (manifest, mismatches) in plan.mismatch_reports() { diff --git a/src/node_lock.rs b/src/node_lock.rs new file mode 100644 index 0000000..49454ac --- /dev/null +++ b/src/node_lock.rs @@ -0,0 +1,718 @@ +//! Node lock の実際の解決版を、実行開始時に固定した age cutoff で検査する。 +//! +//! pnpm は公式 CLI の lockfile-only graph、npm は shrinkwrap/package-lock v2/v3 を読む。 +//! マニフェストの候補を選ぶフィルタと分け、既存版・推移依存も検査する。 + +use crate::registry::{HttpClient, NpmAdapter}; +use crate::update::AgePolicy; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use futures::{StreamExt, stream}; +use indicatif::ProgressBar; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::time::Instant; + +const NODE_AUDIT_BUDGET: Duration = Duration::from_secs(180); +const PNPM_LIST_TIMEOUT: Duration = Duration::from_secs(60); +const PUBLICATION_CONCURRENCY: usize = 8; + +type PublicationDates = HashMap>; + +#[derive(Debug, Clone)] +pub struct NodeAgeViolation { + pub name: String, + pub version: String, + pub released_at: DateTime, + pub cutoff: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct NodeUnverifiedDependency { + pub name: String, + pub version: String, + pub reason: String, +} + +#[derive(Debug, Default)] +pub struct NodeLockAuditResult { + pub checked: usize, + pub violations: Vec, + pub unverified: Vec, + pub errors: Vec, +} + +impl NodeLockAuditResult { + pub fn has_failures(&self) -> bool { + !self.violations.is_empty() || !self.unverified.is_empty() || !self.errors.is_empty() + } + + /// verbose の有無に関わらず、残った版と確認できなかった理由を表示する。 + pub fn failure_messages(&self) -> Vec { + self.violations + .iter() + .map(|item| { + format!( + "{} {} violates --age: published {}, cutoff {} (lockfile was not changed)", + item.name, + item.version, + item.released_at.to_rfc3339(), + item.cutoff.to_rfc3339() + ) + }) + .chain(self.unverified.iter().map(|item| { + format!( + "{} {} could not be checked against --age: {}", + item.name, item.version, item.reason + ) + })) + .chain(self.errors.iter().cloned()) + .collect() + } +} + +#[derive(Debug, Default)] +struct LockSnapshot { + packages: BTreeSet<(String, String)>, + unverified: BTreeSet, +} + +#[async_trait] +trait PublicationProvider: Sync { + async fn fetch_dates(&self, package: &str) -> Result; +} + +#[async_trait] +impl PublicationProvider for NpmAdapter { + async fn fetch_dates(&self, package: &str) -> Result { + self.fetch_publication_dates(package) + .await + .map_err(|error| error.to_string()) + } +} + +/// age 無効時は lock を読まず、CLI も通信も起動しない。 +pub async fn audit_node_lock( + directory: &Path, + package_manager: &str, + policy: &AgePolicy, + bar: Option<&ProgressBar>, +) -> NodeLockAuditResult { + let Some(cutoff) = policy.cutoff() else { + return NodeLockAuditResult::default(); + }; + let deadline = Instant::now() + NODE_AUDIT_BUDGET; + let lock_name = match package_manager { + "pnpm" => "pnpm-lock.yaml", + "npm" => npm_lock_name(directory), + _ => { + return NodeLockAuditResult { + errors: vec![format!( + "{package_manager} lockfile could not be checked against --age: lockfile audit is not supported" + )], + ..Default::default() + }; + } + }; + if let Some(bar) = bar { + bar.set_message(format!("Reading {lock_name}")); + } + let lock_path = directory.join(lock_name); + let original = match read_lock(&lock_path, deadline).await { + Ok(bytes) => bytes, + Err(error) => return audit_error(error), + }; + let snapshot = match package_manager { + "pnpm" => { + let program = which::which_in("pnpm", std::env::var_os("PATH"), directory) + .unwrap_or_else(|_| PathBuf::from("pnpm")); + async { + let mut snapshot = read_pnpm_graph(directory, &program, deadline).await?; + verify_pnpm_registry_sources(directory, &program, &mut snapshot, deadline).await?; + Ok::<_, String>(snapshot) + } + .await + } + _ => serde_json::from_slice::(&original) + .map_err(|error| format!("invalid {lock_name}: {error}")) + .and_then(|value| { + let snapshot = parse_npm_snapshot(&value)?; + verify_npm_manifest_dependencies(directory, &value)?; + Ok(snapshot) + }), + }; + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(error) => return audit_error(error), + }; + let client = match HttpClient::new() { + Ok(client) => client, + Err(error) => return audit_error(error.to_string()), + }; + let mut result = + audit_snapshot(snapshot, cutoff, &NpmAdapter::new(client), deadline, bar).await; + match read_lock(&lock_path, deadline).await { + Ok(bytes) if bytes == original => {} + Ok(_) => result.errors.push(format!( + "{lock_name} changed while --age was being checked; the final lockfile is unverified" + )), + Err(error) => result.errors.push(error), + } + result +} + +fn npm_lock_name(directory: &Path) -> &'static str { + match std::fs::symlink_metadata(directory.join("npm-shrinkwrap.json")) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => "package-lock.json", + _ => "npm-shrinkwrap.json", + } +} + +/// list が integrity-only lock から合成する URL は取得元の証拠にならない。 +/// 明示 tarball は graph の URL で検査し、合成 URL の取得元は実効 registry で検査する。 +async fn verify_pnpm_registry_sources( + directory: &Path, + program: &Path, + snapshot: &mut LockSnapshot, + deadline: Instant, +) -> Result<(), String> { + if snapshot.packages.is_empty() { + return Ok(()); + } + let default = read_pnpm_registry(directory, program, "registry", deadline) + .await + .and_then(|value| value.ok_or_else(|| "effective npm registry is unavailable".to_string())); + let scopes: BTreeSet = snapshot + .packages + .iter() + .filter_map(|(name, _)| { + name.strip_prefix('@')? + .split_once('/') + .map(|(scope, _)| format!("@{scope}:registry")) + }) + .collect(); + let mut evidence = BTreeMap::new(); + for scope in scopes { + let registry = read_pnpm_registry(directory, program, &scope, deadline) + .await + .and_then(|value| value.map(Ok).unwrap_or_else(|| default.clone())); + evidence.insert( + scope, + registry.map(|registry| is_public_npm_registry(®istry)), + ); + } + let default = default.map(|registry| is_public_npm_registry(®istry)); + for (name, version) in std::mem::take(&mut snapshot.packages) { + let source = name + .strip_prefix('@') + .and_then(|rest| rest.split_once('/')) + .map(|(scope, _)| &evidence[&format!("@{scope}:registry")]) + .unwrap_or(&default); + match source { + Ok(true) => { + snapshot.packages.insert((name, version)); + } + Ok(false) => { + snapshot.unverified.insert(NodeUnverifiedDependency { + name, + version, + reason: "effective registry is not the public npm registry".into(), + }); + } + Err(error) => { + snapshot.unverified.insert(NodeUnverifiedDependency { + name, + version, + reason: error.clone(), + }); + } + } + } + Ok(()) +} + +async fn read_pnpm_registry( + directory: &Path, + program: &Path, + key: &str, + deadline: Instant, +) -> Result, String> { + let mut command = tokio::process::Command::new(program); + command + .current_dir(directory) + .args(["config", "get", key, "--json"]) + .kill_on_drop(true); + let output = tokio::time::timeout_at( + deadline.min(Instant::now() + Duration::from_secs(10)), + command.output(), + ) + .await + .map_err(|_| "effective registry verification timed out".to_string())? + .map_err(|error| format!("effective registry verification failed: {error}"))?; + if !output.status.success() || !output.stderr.is_empty() { + return Err("effective registry configuration could not be verified".into()); + } + match serde_json::from_slice::(&output.stdout) + .map_err(|_| "effective registry configuration was not valid JSON".to_string())? + { + Value::Null => Ok(None), + Value::String(value) => Ok(Some(value)), + _ => Err("effective registry configuration had an unsupported value".into()), + } +} + +fn is_public_npm_registry(registry: &str) -> bool { + reqwest::Url::parse(registry).is_ok_and(|url| { + matches!(url.scheme(), "https" | "http") + && url.host_str() == Some("registry.npmjs.org") + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + }) +} + +fn audit_error(error: String) -> NodeLockAuditResult { + NodeLockAuditResult { + errors: vec![error], + ..Default::default() + } +} + +async fn read_lock(path: &Path, deadline: Instant) -> Result, String> { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + match tokio::time::timeout_at(deadline, tokio::fs::read(path)).await { + Ok(Ok(bytes)) => Ok(bytes), + Ok(Err(error)) => Err(format!("could not read {name} for --age audit: {error}")), + Err(_) => Err(format!( + "{name} --age audit timed out; the lockfile is unverified" + )), + } +} + +async fn read_pnpm_graph( + directory: &Path, + program: &Path, + deadline: Instant, +) -> Result { + let mut command = tokio::process::Command::new(program); + command.current_dir(directory).args([ + "list", + "--depth", + "Infinity", + "--lockfile-only", + "--json", + ]); + if directory.join("pnpm-workspace.yaml").exists() { + command.args(["--recursive", "--include-workspace-root"]); + } + command.kill_on_drop(true); + let command_deadline = deadline.min(Instant::now() + PNPM_LIST_TIMEOUT); + let output = tokio::time::timeout_at(command_deadline, command.output()) + .await + .map_err(|_| "pnpm list --lockfile-only timed out; the lockfile is unverified".to_string())? + .map_err(|error| format!("pnpm list --lockfile-only failed: {error}"))?; + if !output.status.success() { + return Err(format!( + "pnpm list --lockfile-only failed ({}): {}", + output.status, + redact_command_output(&String::from_utf8_lossy(&output.stderr)).trim() + )); + } + let value: Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("invalid pnpm lockfile graph: {error}"))?; + let snapshot = parse_pnpm_snapshot(&value)?; + verify_pnpm_manifest_dependencies(directory, &value)?; + if !output.stderr.is_empty() { + return Err(format!( + "pnpm list --lockfile-only emitted a warning; the lockfile is unverified: {}", + redact_command_output(&String::from_utf8_lossy(&output.stderr)).trim() + )); + } + Ok(snapshot) +} + +/// list は manifest と食い違う空 importer にも exit 0 を返すため、直接依存の欠落を検査する。 +/// optionalDependencies は platform 等の理由で無いことがあるので必須とはしない。 +fn verify_pnpm_manifest_dependencies(directory: &Path, value: &Value) -> Result<(), String> { + let projects = value + .as_array() + .ok_or_else(|| "invalid pnpm project graph".to_string())?; + let root = directory + .canonicalize() + .map_err(|error| format!("could not locate pnpm audit directory: {error}"))?; + let mut saw_root = false; + for project in projects { + let project_path = match project.get("path").and_then(Value::as_str) { + Some(path) => PathBuf::from(path), + None if projects.len() == 1 => root.clone(), + None => return Err( + "pnpm workspace graph omitted a project path; completeness could not be checked" + .into(), + ), + }; + let project_path = project_path + .canonicalize() + .map_err(|error| format!("could not locate pnpm graph project: {error}"))?; + if !project_path.starts_with(&root) { + return Err("pnpm graph references a project outside the audited workspace".into()); + } + saw_root |= project_path == root; + let manifest = std::fs::read(project_path.join("package.json")) + .map_err(|error| format!("could not read pnpm graph project package.json: {error}"))?; + let manifest: Value = serde_json::from_slice(&manifest) + .map_err(|error| format!("invalid pnpm graph project package.json: {error}"))?; + for section in ["dependencies", "devDependencies"] { + let Some(required) = manifest.get(section) else { + continue; + }; + let required = required + .as_object() + .ok_or_else(|| format!("package.json has invalid {section}"))?; + for name in required.keys() { + if manifest + .get("optionalDependencies") + .and_then(Value::as_object) + .is_some_and(|optional| optional.contains_key(name)) + { + continue; + } + if !["dependencies", "devDependencies", "optionalDependencies"] + .iter() + .any(|section| { + project + .get(section) + .and_then(Value::as_object) + .is_some_and(|dependencies| dependencies.contains_key(name)) + }) + { + return Err(format!( + "pnpm lockfile graph omitted required dependency {name}; the lockfile may be stale or unsupported" + )); + } + } + } + } + if !saw_root { + return Err( + "pnpm graph omitted the workspace root; completeness could not be checked".into(), + ); + } + Ok(()) +} + +fn verify_npm_manifest_dependencies(directory: &Path, value: &Value) -> Result<(), String> { + let manifest = std::fs::read(directory.join("package.json")) + .map_err(|error| format!("could not read package.json for npm lockfile audit: {error}"))?; + let manifest: Value = serde_json::from_slice(&manifest) + .map_err(|error| format!("invalid package.json for npm lockfile audit: {error}"))?; + let packages = value + .get("packages") + .and_then(Value::as_object) + .ok_or_else(|| "npm lockfile has no valid packages table".to_string())?; + verify_npm_required_dependencies("", &manifest, packages, true)?; + for (path, package) in packages { + if !package.is_object() { + return Err(format!( + "npm lockfile has an invalid package record at {path}" + )); + } + if package.get("link").and_then(Value::as_bool) == Some(true) { + continue; + } + verify_npm_required_dependencies( + path, + package, + packages, + !path.split('/').any(|part| part == "node_modules"), + )?; + } + Ok(()) +} + +fn verify_npm_required_dependencies( + issuer: &str, + package: &Value, + packages: &serde_json::Map, + include_dev: bool, +) -> Result<(), String> { + for section in ["dependencies", "devDependencies"] { + if section == "devDependencies" && !include_dev { + continue; + } + let Some(required) = package.get(section) else { + continue; + }; + let required = required + .as_object() + .ok_or_else(|| format!("npm lockfile package {issuer} has invalid {section}"))?; + for name in required.keys() { + if package + .get("optionalDependencies") + .and_then(Value::as_object) + .is_some_and(|optional| optional.contains_key(name)) + { + continue; + } + resolve_npm_dependency(issuer, name, packages).ok_or_else(|| { + format!("npm lockfile omitted required dependency {name} of {issuer}; the lockfile may be stale") + })?; + // npm owns range, override, and alias resolution. The audit checks the + // actual lock package identity and version when collecting its source. + } + } + Ok(()) +} + +/// Node resolution searches the closest node_modules first, then its ancestors. +/// Workspace links refer to another record in the same packages table. +fn resolve_npm_dependency<'a>( + issuer: &str, + name: &str, + packages: &'a serde_json::Map, +) -> Option<&'a Value> { + let mut current = issuer; + loop { + if current.rsplit('/').next() != Some("node_modules") { + let candidate = if current.is_empty() { + format!("node_modules/{name}") + } else { + format!("{current}/node_modules/{name}") + }; + if let Some(mut dependency) = packages.get(&candidate) { + let mut seen = BTreeSet::new(); + while dependency.get("link").and_then(Value::as_bool) == Some(true) { + let target = dependency.get("resolved").and_then(Value::as_str)?; + if !seen.insert(target) { + return None; + } + dependency = packages.get(target)?; + } + return Some(dependency); + } + } + if current.is_empty() { + return None; + } + current = current + .rsplit_once('/') + .map(|(parent, _)| parent) + .unwrap_or(""); + } +} + +fn redact_command_output(output: &str) -> String { + static URLS: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r#"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s\"'<>]+"#).unwrap() + }); + URLS.replace_all(output, |captures: ®ex::Captures<'_>| { + crate::registry::redact_url(&captures[0]) + }) + .into_owned() +} + +fn parse_pnpm_snapshot(value: &Value) -> Result { + let projects = value + .as_array() + .filter(|projects| !projects.is_empty()) + .ok_or_else(|| "pnpm lockfile graph contains no project records".to_string())?; + let mut snapshot = LockSnapshot::default(); + let mut pending: Vec<&Value> = projects.iter().collect(); + while let Some(parent) = pending.pop() { + if !parent.is_object() { + return Err("pnpm lockfile graph has an invalid project or dependency record".into()); + } + for section in ["dependencies", "devDependencies", "optionalDependencies"] { + let Some(dependencies) = parent.get(section) else { + continue; + }; + let dependencies = dependencies + .as_object() + .ok_or_else(|| format!("pnpm lockfile graph has invalid {section}"))?; + for (alias, dependency) in dependencies { + collect_package(&mut snapshot, alias, dependency); + pending.push(dependency); + } + } + } + Ok(snapshot) +} + +fn parse_npm_snapshot(value: &Value) -> Result { + if !matches!( + value.get("lockfileVersion").and_then(Value::as_u64), + Some(2 | 3) + ) { + return Err("npm lockfile --age audit requires lockfileVersion 2 or 3".into()); + } + let packages = value + .get("packages") + .and_then(Value::as_object) + .ok_or_else(|| "npm lockfile has no valid packages table".to_string())?; + if !packages.get("").is_some_and(Value::is_object) { + return Err("npm lockfile omitted its root package record".into()); + } + let mut snapshot = LockSnapshot::default(); + for (path, package) in packages { + let Some((_, alias)) = path.rsplit_once("node_modules/") else { + continue; + }; + if package.get("link").and_then(Value::as_bool) == Some(true) { + continue; + } + collect_package(&mut snapshot, alias, package); + } + Ok(snapshot) +} + +fn collect_package(snapshot: &mut LockSnapshot, alias: &str, package: &Value) { + let version = package + .get("version") + .and_then(Value::as_str) + .unwrap_or(""); + let resolved = package.get("resolved").and_then(Value::as_str); + if resolved.is_some_and(is_non_registry_source) || is_non_registry_source(version) { + return; + } + if let Some(name) = resolved.and_then(|source| public_tarball_package(source, version)) { + snapshot.packages.insert((name, version.to_string())); + } else { + snapshot.unverified.insert(NodeUnverifiedDependency { + name: alias.to_string(), + version: version.to_string(), + reason: "release source is not a verified public npm registry tarball".into(), + }); + } +} + +fn is_non_registry_source(source: &str) -> bool { + [ + "link:", + "file:", + "workspace:", + "git+", + "git:", + "github:", + "git@", + ] + .iter() + .any(|prefix| source.starts_with(prefix)) +} + +/// 名前は alias/from ではなく、公開 npm の tarball URL と完全版の一致から決める。 +fn public_tarball_package(source: &str, version: &str) -> Option { + semver::Version::parse(version).ok()?; + let url = reqwest::Url::parse(source).ok()?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str() != Some("registry.npmjs.org") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return None; + } + let path = url + .path() + .replace("%2f", "/") + .replace("%2F", "/") + .replace("%40", "@") + .replace("%2B", "+") + .replace("%2b", "+"); + let (name, tarball) = path.strip_prefix('/')?.split_once("/-/")?; + let basename = name.rsplit('/').next()?; + let valid_segment = |segment: &str| { + !segment.is_empty() + && segment != "." + && segment != ".." + && segment.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'~') + }) + }; + let valid_name = match name.strip_prefix('@') { + Some(scoped) => scoped + .split_once('/') + .is_some_and(|(scope, name)| valid_segment(scope) && valid_segment(name)), + None => valid_segment(name), + }; + (valid_name && tarball == format!("{basename}-{version}.tgz")).then(|| name.to_string()) +} + +async fn audit_snapshot( + snapshot: LockSnapshot, + cutoff: DateTime, + provider: &impl PublicationProvider, + deadline: Instant, + bar: Option<&ProgressBar>, +) -> NodeLockAuditResult { + let mut result = NodeLockAuditResult { + unverified: snapshot.unverified.into_iter().collect(), + ..Default::default() + }; + let mut groups: BTreeMap> = BTreeMap::new(); + for (name, version) in snapshot.packages { + groups.entry(name).or_default().push(version); + } + if let Some(bar) = bar { + bar.set_length(groups.len() as u64); + bar.set_position(0); + } + let mut requests = stream::iter(groups) + .map(|(name, versions)| async move { + if let Some(bar) = bar { + bar.set_message(format!("Checking npm {name}")); + } + let dates = if Instant::now() >= deadline { + Err("lockfile age audit timed out".into()) + } else { + tokio::time::timeout_at(deadline, provider.fetch_dates(&name)) + .await + .unwrap_or_else(|_| Err("lockfile age audit timed out".into())) + }; + (name, versions, dates) + }) + .buffer_unordered(PUBLICATION_CONCURRENCY); + while let Some((name, versions, dates)) = requests.next().await { + for version in versions { + match &dates { + Ok(dates) => match dates.get(&version) { + Some(&released_at) => { + result.checked += 1; + if released_at > cutoff { + result.violations.push(NodeAgeViolation { + name: name.clone(), + version, + released_at, + cutoff, + }); + } + } + None => result.unverified.push(NodeUnverifiedDependency { + name: name.clone(), + version, + reason: "release date unavailable".into(), + }), + }, + Err(error) => result.unverified.push(NodeUnverifiedDependency { + name: name.clone(), + version, + reason: error.clone(), + }), + } + } + if let Some(bar) = bar { + bar.inc(1); + } + } + result + .violations + .sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version))); + result.unverified.sort(); + result +} + +#[cfg(test)] +mod tests; diff --git a/src/node_lock/tests.rs b/src/node_lock/tests.rs new file mode 100644 index 0000000..dd2463a --- /dev/null +++ b/src/node_lock/tests.rs @@ -0,0 +1,762 @@ +use super::*; +use serde_json::json; +use std::sync::Mutex; + +#[derive(Default)] +struct FakeDates { + dates: HashMap, + requests: Mutex>, +} + +#[async_trait] +impl PublicationProvider for FakeDates { + async fn fetch_dates(&self, package: &str) -> Result { + self.requests.lock().unwrap().push(package.into()); + self.dates + .get(package) + .cloned() + .ok_or_else(|| "registry unavailable".into()) + } +} + +fn policy() -> AgePolicy { + AgePolicy { + min_age: Some(Duration::from_secs(14 * 86400)), + evaluated_at: "2026-10-05T00:00:00Z".parse().unwrap(), + exemptions: Default::default(), + } +} + +fn dates(entries: &[(&str, &str)]) -> PublicationDates { + entries + .iter() + .map(|(version, date)| (version.to_string(), date.parse().unwrap())) + .collect() +} + +fn package(name: &str, version: &str) -> Value { + let basename = name.rsplit('/').next().unwrap(); + json!({ + "from": name, + "version": version, + "resolved": format!("https://registry.npmjs.org/{name}/-/{basename}-{version}.tgz") + }) +} + +async fn check(snapshot: LockSnapshot, provider: &impl PublicationProvider) -> NodeLockAuditResult { + audit_snapshot( + snapshot, + policy().cutoff().unwrap(), + provider, + Instant::now() + Duration::from_secs(1), + None, + ) + .await +} + +#[tokio::test] +async fn pnpm_snapshot_checks_direct_and_transitive_releases() { + let mut direct = package("example-parent", "1.0.0"); + direct["dependencies"] = json!({"example-child": package("example-child", "1.0.1")}); + let snapshot = parse_pnpm_snapshot(&json!([{ + "name": "example-app", "dependencies": {"example-parent": direct} + }])) + .unwrap(); + let provider = FakeDates { + dates: HashMap::from([ + ( + "example-parent".into(), + dates(&[("1.0.0", "2026-09-01T00:00:00Z")]), + ), + ( + "example-child".into(), + dates(&[("1.0.1", "2026-09-30T00:00:00Z")]), + ), + ]), + ..Default::default() + }; + let result = check(snapshot, &provider).await; + assert_eq!(result.checked, 2); + assert!(result.has_failures()); + assert_eq!(result.violations.len(), 1); + assert_eq!(result.violations[0].name, "example-child"); + assert_eq!(result.violations[0].version, "1.0.1"); + assert!(result.failure_messages()[0].contains("2026-09-30")); +} + +#[tokio::test] +async fn pnpm_deep_graph_preserves_alias_and_optional_releases() { + let mut chain = package("chain-15", "1.0.0"); + chain["optionalDependencies"] = json!({"optional-deep": package("optional-deep", "1.0.0")}); + for index in (1..15).rev() { + let mut parent = package(&format!("chain-{index}"), "1.0.0"); + parent["dependencies"] = json!({format!("chain-{}", index + 1): chain}); + chain = parent; + } + let graph = json!([{ + "dependencies": {"chain-1": chain, "alias": package("@example/real", "1.0.0")}, + "optionalDependencies": {"optional-root": package("optional-root", "1.0.0")} + }]); + let snapshot = parse_pnpm_snapshot(&graph).unwrap(); + assert_eq!(snapshot.packages.len(), 18); + assert!(snapshot.unverified.is_empty()); + let mut provider = FakeDates::default(); + for (name, version) in &snapshot.packages { + provider + .dates + .insert(name.clone(), dates(&[(version, "2026-09-01T00:00:00Z")])); + } + for name in ["optional-deep", "optional-root", "@example/real"] { + provider + .dates + .insert(name.into(), dates(&[("1.0.0", "2026-09-30T00:00:00Z")])); + } + let result = check(snapshot, &provider).await; + assert_eq!(result.checked, 18); + assert_eq!(result.violations.len(), 3); + assert_eq!( + result + .violations + .iter() + .map(|item| item.name.as_str()) + .collect::>(), + ["@example/real", "optional-deep", "optional-root"] + ); + assert!( + !provider + .requests + .lock() + .unwrap() + .iter() + .any(|name| name == "alias") + ); +} + +#[tokio::test] +async fn npm_aliases_use_tarball_identity_and_deduplicate_fetches() { + let snapshot = parse_npm_snapshot(&json!({ + "lockfileVersion": 3, + "packages": { + "": {"name": "example-app", "version": "1.0.0"}, + "node_modules/alias": package("@example/actual", "1.0.0"), + "node_modules/parent/node_modules/alias": package("@example/actual", "1.0.0"), + "node_modules/another": package("@example/actual", "1.1.0") + } + })) + .unwrap(); + let provider = FakeDates { + dates: HashMap::from([( + "@example/actual".into(), + dates(&[ + ("1.0.0", "2026-09-01T00:00:00Z"), + ("1.1.0", "2026-09-22T00:00:00Z"), + ]), + )]), + ..Default::default() + }; + let result = check(snapshot, &provider).await; + assert_eq!(*provider.requests.lock().unwrap(), ["@example/actual"]); + assert_eq!(result.checked, 2); + assert_eq!(result.violations[0].version, "1.1.0"); +} + +#[tokio::test] +async fn private_and_unknown_sources_are_never_queried_on_public_npm() { + let snapshot = parse_pnpm_snapshot(&json!([{ + "dependencies": { + "example-private": {"from": "example-private", "version": "1.0.0", + "resolved": "https://registry.example.com/example-private/-/example-private-1.0.0.tgz"}, + "unknown": {"version": "1.0.0"}, + "local": {"version": "link:../local", "resolved": "link:../local"}, + "git": {"version": "git+https://example.com/library.git#abc", + "resolved": "git+https://example.com/library.git#abc"} + } + }])) + .unwrap(); + let provider = FakeDates::default(); + let result = check(snapshot, &provider).await; + assert!(provider.requests.lock().unwrap().is_empty()); + assert_eq!(result.unverified.len(), 2); + assert!(result.has_failures()); +} + +#[tokio::test] +async fn missing_dates_and_fetch_errors_are_failures() { + let snapshot = parse_pnpm_snapshot(&json!([{"dependencies": { + "no-date": package("no-date", "1.0.0"), + "unavailable": package("unavailable", "1.0.0") + }}])) + .unwrap(); + let provider = FakeDates { + dates: HashMap::from([("no-date".into(), PublicationDates::new())]), + ..Default::default() + }; + let result = check(snapshot, &provider).await; + assert!(result.has_failures()); + assert_eq!(result.checked, 0); + assert_eq!(result.unverified.len(), 2); + assert!( + result + .failure_messages() + .iter() + .any(|line| line.contains("registry unavailable")) + ); +} + +#[tokio::test] +async fn unchanged_old_versions_and_cutoff_boundary_are_safe() { + let snapshot = parse_pnpm_snapshot(&json!([{"dependencies": { + "boundary": package("boundary", "1.0.0") + }}])) + .unwrap(); + let provider = FakeDates { + dates: HashMap::from([( + "boundary".into(), + dates(&[("1.0.0", "2026-09-21T00:00:00Z")]), + )]), + ..Default::default() + }; + let result = check(snapshot, &provider).await; + assert_eq!(result.checked, 1); + assert!(!result.has_failures()); +} + +#[tokio::test] +async fn node_age_disabled_never_reads_a_lock() { + let policy = AgePolicy { + min_age: None, + ..policy() + }; + let result = audit_node_lock(Path::new("missing-project"), "pnpm", &policy, None).await; + assert_eq!(result.checked, 0); + assert!(!result.has_failures()); +} + +#[tokio::test] +async fn absent_invalid_and_unsupported_locks_report_failure_without_fetching() { + let directory = tempfile::tempdir().unwrap(); + let absent = audit_node_lock(directory.path(), "npm", &policy(), None).await; + assert!(absent.has_failures()); + assert_eq!(absent.checked, 0); + std::fs::write(directory.path().join("package-lock.json"), "invalid JSON").unwrap(); + let invalid = audit_node_lock(directory.path(), "npm", &policy(), None).await; + assert!(invalid.has_failures()); + assert!(invalid.errors[0].contains("invalid package-lock.json")); + for manager in ["yarn", "bun"] { + let result = audit_node_lock(directory.path(), manager, &policy(), None).await; + assert!(result.has_failures()); + assert!(result.errors[0].contains("not supported")); + } +} + +#[test] +fn npm_v2_links_are_skipped_but_registry_packages_remain_auditable() { + let snapshot = parse_npm_snapshot(&json!({ + "lockfileVersion": 2, + "packages": { + "": {"name": "example-app"}, + "packages/local": {"name": "local", "version": "1.0.0"}, + "node_modules/local": {"link": true, "resolved": "packages/local"}, + "node_modules/public": package("public", "1.0.0") + } + })) + .unwrap(); + assert_eq!(snapshot.packages.len(), 1); + assert!(snapshot.unverified.is_empty()); +} + +#[tokio::test] +async fn npm_empty_stale_lock_is_unverified_without_network_requests() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write( + directory.path().join("package.json"), + r#"{"dependencies":{"required":"1.0.0"}}"#, + ) + .unwrap(); + std::fs::write( + directory.path().join("package-lock.json"), + r#"{"lockfileVersion":3,"packages":{"":{}}}"#, + ) + .unwrap(); + let result = audit_node_lock(directory.path(), "npm", &policy(), None).await; + assert!(result.has_failures()); + assert!(result.errors[0].contains("required")); + assert_eq!(result.checked, 0); +} + +#[tokio::test] +async fn npm_shrinkwrap_takes_priority_over_package_lock() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("package.json"), "{}").unwrap(); + std::fs::write( + directory.path().join("package-lock.json"), + r#"{"lockfileVersion":3,"packages":{"":{}}}"#, + ) + .unwrap(); + std::fs::write(directory.path().join("npm-shrinkwrap.json"), "invalid JSON").unwrap(); + let result = audit_node_lock(directory.path(), "npm", &policy(), None).await; + assert!(result.has_failures()); + assert!(result.errors[0].contains("invalid npm-shrinkwrap.json")); + assert_eq!(result.checked, 0); + let shrinkwrap = json!({"lockfileVersion":2,"packages":{"":{},"node_modules/young":package("young","1.0.0")}}); + std::fs::write( + directory.path().join("npm-shrinkwrap.json"), + shrinkwrap.to_string(), + ) + .unwrap(); + assert_eq!(npm_lock_name(directory.path()), "npm-shrinkwrap.json"); + let snapshot = parse_npm_snapshot(&shrinkwrap).unwrap(); + let provider = FakeDates { + dates: HashMap::from([("young".into(), dates(&[("1.0.0", "2026-09-30T00:00:00Z")]))]), + ..Default::default() + }; + let result = check(snapshot, &provider).await; + assert_eq!(result.violations.len(), 1); +} + +#[tokio::test] +async fn npm_missing_transitive_dependency_is_not_a_success() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write( + directory.path().join("package.json"), + r#"{"dependencies":{"parent":"1.0.0"}}"#, + ) + .unwrap(); + let mut parent = package("parent", "1.0.0"); + parent["dependencies"] = json!({"missing-child":"^1.0.0"}); + let lock = json!({"lockfileVersion":3,"packages":{"":{},"node_modules/parent":parent}}); + std::fs::write(directory.path().join("package-lock.json"), lock.to_string()).unwrap(); + let result = audit_node_lock(directory.path(), "npm", &policy(), None).await; + assert!(result.has_failures()); + assert_eq!(result.checked, 0); + assert!(result.errors[0].contains("missing-child")); +} + +#[test] +fn npm_required_dependencies_resolve_hoisting_workspaces_and_aliases() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write( + directory.path().join("package.json"), + r#"{"dependencies":{"parent":"^1.0.0","member":"file:packages/member"}}"#, + ) + .unwrap(); + let mut parent = package("parent", "1.2.0"); + parent["dependencies"] = json!({"child":"~1.2.0","alias":"npm:@example/actual@^1.0.0","alias-latest":"npm:@example/actual","optional":"1.0.0"}); + parent["optionalDependencies"] = json!({"optional":"1.0.0"}); + parent["devDependencies"] = json!({"not-installed":"1.0.0"}); + let lock = json!({"packages":{ + "":{}, "node_modules/parent":parent, + "node_modules/child":package("child","1.2.3"), + "node_modules/alias":package("@example/actual","1.1.0"), + "node_modules/alias-latest":package("@example/actual","1.1.0"), + "node_modules/member":{"link":true,"resolved":"packages/member"}, + "packages/member":{"name":"member","version":"1.0.0","devDependencies":{"dev":"1.0.0"}}, + "node_modules/dev":package("dev","1.0.0") + }}); + verify_npm_manifest_dependencies(directory.path(), &lock).unwrap(); + let mut missing_dev = lock.clone(); + missing_dev["packages"] + .as_object_mut() + .unwrap() + .remove("node_modules/dev"); + assert!( + verify_npm_manifest_dependencies(directory.path(), &missing_dev) + .unwrap_err() + .contains("dev") + ); +} + +#[tokio::test] +async fn npm_overrides_alias_replacements_and_empty_or_audit_actual_lock_versions() { + let directory = tempfile::tempdir().unwrap(); + let manifest = json!({ + "dependencies":{"parent":"1.0.0","empty-or":"1.0.0 ||"}, + "overrides":{"child":"2.0.0","original":"npm:replacement@2.0.0"} + }); + std::fs::write(directory.path().join("package.json"), manifest.to_string()).unwrap(); + let mut parent = package("parent", "1.0.0"); + parent["dependencies"] = json!({"child":"^1.0.0","original":"^1.0.0"}); + let lock = json!({"lockfileVersion":3,"packages":{ + "":manifest, + "node_modules/parent":parent, + "node_modules/child":package("child","2.0.0"), + "node_modules/original":package("replacement","2.0.0"), + "node_modules/empty-or":package("empty-or","2.0.0") + }}); + verify_npm_manifest_dependencies(directory.path(), &lock).unwrap(); + let snapshot = parse_npm_snapshot(&lock).unwrap(); + let provider = FakeDates { + dates: HashMap::from([ + ("parent".into(), dates(&[("1.0.0", "2026-09-01T00:00:00Z")])), + ("child".into(), dates(&[("2.0.0", "2026-09-30T00:00:00Z")])), + ( + "replacement".into(), + dates(&[("2.0.0", "2026-09-30T00:00:00Z")]), + ), + ( + "empty-or".into(), + dates(&[("2.0.0", "2026-09-30T00:00:00Z")]), + ), + ]), + ..Default::default() + }; + let result = check(snapshot, &provider).await; + assert_eq!(result.checked, 4); + assert_eq!(result.violations.len(), 3); + assert!(result.unverified.is_empty()); + assert_eq!( + result + .violations + .iter() + .map(|item| (item.name.as_str(), item.version.as_str())) + .collect::>(), + [ + ("child", "2.0.0"), + ("empty-or", "2.0.0"), + ("replacement", "2.0.0") + ] + ); + let mut requests = provider.requests.lock().unwrap().clone(); + requests.sort(); + assert_eq!(requests, ["child", "empty-or", "parent", "replacement"]); +} + +#[test] +fn malformed_and_unsupported_lockfiles_are_not_empty_successes() { + assert!(parse_npm_snapshot(&json!({"lockfileVersion": 1, "dependencies": {}})).is_err()); + assert!(parse_npm_snapshot(&json!({"lockfileVersion": 3})).is_err()); + assert!(parse_npm_snapshot(&json!({"lockfileVersion": 3,"packages":{}})).is_err()); + assert!(parse_pnpm_snapshot(&json!({})).is_err()); + assert!(parse_pnpm_snapshot(&json!([])).is_err()); + assert!(parse_pnpm_snapshot(&json!([{"dependencies": "not-an-object"}])).is_err()); +} + +#[test] +fn pnpm_workspace_graphs_cover_root_members_and_all_dependency_sections() { + let snapshot = parse_pnpm_snapshot(&json!([ + {"dependencies": {"root": package("root", "1.0.0")}}, + {"devDependencies": {"dev": package("dev", "1.0.0")}, + "optionalDependencies": {"optional": package("optional", "1.0.0")}} + ])) + .unwrap(); + assert_eq!(snapshot.packages.len(), 3); +} + +#[test] +fn public_registry_source_requires_an_exact_host_and_package_tarball() { + assert_eq!( + public_tarball_package( + "https://registry.npmjs.org/@example/pkg/-/pkg-1.0.0.tgz", + "1.0.0" + ), + Some("@example/pkg".into()) + ); + for source in [ + "https://registry.npmjs.org.example.com/pkg/-/pkg-1.0.0.tgz", + "https://registry.npmjs.org:8443/pkg/-/pkg-1.0.0.tgz", + "https://registry.npmjs.org/pkg/-/other-1.0.0.tgz", + "https://registry.npmjs.org/pkg/-/pkg-2.0.0.tgz", + ] { + assert!( + public_tarball_package(source, "1.0.0").is_none(), + "{source}" + ); + } + let mut source = reqwest::Url::parse("https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz").unwrap(); + source.set_username("example").unwrap(); + source.set_password(Some("placeholder")).unwrap(); + assert!(public_tarball_package(source.as_str(), "1.0.0").is_none()); +} + +#[test] +fn pnpm_errors_redact_credentials_in_registry_urls() { + let mut source = reqwest::Url::parse("https://registry.example.com/pkg").unwrap(); + source.set_username("example").unwrap(); + source.set_password(Some("placeholder")).unwrap(); + let error = redact_command_output(&format!("unable to read {source}")); + assert!(error.contains("https://***@registry.example.com/pkg")); + assert!(!error.contains("placeholder")); +} + +#[tokio::test] +async fn audit_deadline_keeps_unchecked_versions_as_failures() { + let snapshot = + parse_pnpm_snapshot(&json!([{"dependencies": {"pkg": package("pkg", "1.0.0")}}])).unwrap(); + let provider = FakeDates::default(); + let result = audit_snapshot( + snapshot, + policy().cutoff().unwrap(), + &provider, + Instant::now(), + None, + ) + .await; + assert!(result.has_failures()); + assert_eq!(result.unverified.len(), 1); + assert!(provider.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn metadata_wait_is_bounded_by_the_remaining_audit_budget() { + struct SlowDates; + #[async_trait] + impl PublicationProvider for SlowDates { + async fn fetch_dates(&self, _: &str) -> Result { + tokio::time::sleep(Duration::from_secs(1)).await; + unreachable!("the audit budget must cancel this request"); + } + } + let snapshot = parse_pnpm_snapshot(&json!([{"dependencies": { + "pkg": package("pkg", "1.0.0") + }}])) + .unwrap(); + let result = audit_snapshot( + snapshot, + policy().cutoff().unwrap(), + &SlowDates, + Instant::now() + Duration::from_millis(20), + None, + ) + .await; + assert_eq!(result.checked, 0); + assert_eq!(result.unverified.len(), 1); + assert!(result.unverified[0].reason.contains("timed out")); +} + +#[cfg(unix)] +fn fake_pnpm(directory: &Path, script: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let program = directory.join("fake-pnpm"); + std::fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + program +} + +#[cfg(unix)] +#[tokio::test] +async fn pnpm_synthesized_public_urls_require_effective_public_registry() { + let directory = tempfile::tempdir().unwrap(); + let program = fake_pnpm( + directory.path(), + "printf '\"https://registry.example.com/\"'", + ); + let mut snapshot = parse_pnpm_snapshot( + &json!([{"dependencies":{"private-name":package("private-name","1.0.0")}}]), + ) + .unwrap(); + verify_pnpm_registry_sources( + directory.path(), + &program, + &mut snapshot, + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let provider = FakeDates::default(); + let result = check(snapshot, &provider).await; + assert!(provider.requests.lock().unwrap().is_empty()); + assert_eq!(result.unverified.len(), 1); + assert!(result.unverified[0].reason.contains("effective registry")); +} + +#[cfg(unix)] +#[tokio::test] +async fn pnpm_scoped_registry_overrides_default_and_private_tarballs_remain_unverified() { + let directory = tempfile::tempdir().unwrap(); + let program = fake_pnpm( + directory.path(), + "case \"$3\" in registry) printf '\"https://registry.npmjs.org/\"';; '@private:registry') printf '\"https://registry.example.com/\"';; *) printf null;; esac", + ); + let mut private = package("explicit-private", "1.0.0"); + private["resolved"] = + json!("https://registry.example.com/explicit-private/-/explicit-private-1.0.0.tgz"); + let mut snapshot = parse_pnpm_snapshot(&json!([{"dependencies":{ + "@private/name":package("@private/name","1.0.0"), + "@example/public":package("@example/public","1.0.0"), + "explicit-public":package("explicit-public","1.0.0"), + "explicit-private":private + }}])) + .unwrap(); + verify_pnpm_registry_sources( + directory.path(), + &program, + &mut snapshot, + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let provider = FakeDates { + dates: HashMap::from([ + ( + "@example/public".into(), + dates(&[("1.0.0", "2026-09-01T00:00:00Z")]), + ), + ( + "explicit-public".into(), + dates(&[("1.0.0", "2026-09-01T00:00:00Z")]), + ), + ]), + ..Default::default() + }; + let result = check(snapshot, &provider).await; + assert_eq!(result.checked, 2); + assert_eq!(result.unverified.len(), 2); + let requests = provider.requests.lock().unwrap(); + assert!(!requests.iter().any(|name| name.contains("private"))); +} + +#[cfg(unix)] +#[tokio::test] +async fn pnpm_unreadable_registry_config_never_sends_names_to_public_npm() { + let directory = tempfile::tempdir().unwrap(); + for script in [ + "exit 7", + "printf 'not JSON'", + "printf null", + "printf '\"https://registry.npmjs.org/\"'; printf warning >&2", + ] { + let program = fake_pnpm(directory.path(), script); + let mut snapshot = + parse_pnpm_snapshot(&json!([{"dependencies":{"unknown":package("unknown","1.0.0")}}])) + .unwrap(); + verify_pnpm_registry_sources( + directory.path(), + &program, + &mut snapshot, + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let provider = FakeDates::default(); + let result = check(snapshot, &provider).await; + assert!(provider.requests.lock().unwrap().is_empty()); + assert_eq!(result.unverified.len(), 1); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn pnpm_list_reads_every_workspace_from_the_lock() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("package.json"), "{}").unwrap(); + std::fs::write( + directory.path().join("pnpm-workspace.yaml"), + "packages: []\n", + ) + .unwrap(); + let program = fake_pnpm( + directory.path(), + "printf '%s\\n' \"$@\" > arguments.txt\nprintf '[{\"dependencies\":{}}]'", + ); + let snapshot = read_pnpm_graph( + directory.path(), + &program, + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + assert!(snapshot.packages.is_empty()); + let arguments = std::fs::read_to_string(directory.path().join("arguments.txt")).unwrap(); + assert_eq!( + arguments.lines().collect::>(), + [ + "list", + "--depth", + "Infinity", + "--lockfile-only", + "--json", + "--recursive", + "--include-workspace-root" + ] + ); +} + +#[test] +fn pnpm_empty_stale_importer_is_unverified_even_when_list_exits_successfully() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write( + directory.path().join("package.json"), + r#"{"dependencies":{"required":"^1.0.0"},"optionalDependencies":{"optional":"1.0.0"}}"#, + ) + .unwrap(); + let stale = json!([{"path": directory.path(), "dependencies": {}}]); + assert!( + verify_pnpm_manifest_dependencies(directory.path(), &stale) + .unwrap_err() + .contains("required") + ); + let complete = json!([{"path": directory.path(), "dependencies": {"required": package("required", "1.0.0")}}]); + assert!(verify_pnpm_manifest_dependencies(directory.path(), &complete).is_ok()); +} + +#[test] +fn pnpm_workspace_importer_validation_includes_member_dev_dependencies_and_root() { + let directory = tempfile::tempdir().unwrap(); + let member = directory.path().join("packages/member"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write(directory.path().join("package.json"), "{}").unwrap(); + std::fs::write( + member.join("package.json"), + r#"{"devDependencies":{"dev-required":"1.0.0"}}"#, + ) + .unwrap(); + let stale = json!([{"path": directory.path()}, {"path": member}]); + assert!( + verify_pnpm_manifest_dependencies(directory.path(), &stale) + .unwrap_err() + .contains("dev-required") + ); + let complete_member = json!({"path": member, "devDependencies": {"dev-required": package("dev-required", "1.0.0")}}); + let missing_root = json!([complete_member]); + assert!( + verify_pnpm_manifest_dependencies(directory.path(), &missing_root) + .unwrap_err() + .contains("root") + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn pnpm_successful_list_with_lock_warnings_is_unverified() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("package.json"), "{}").unwrap(); + let program = fake_pnpm( + directory.path(), + "printf 'Ignoring unsupported lockfile' >&2\nprintf '[{\"dependencies\":{}}]'", + ); + let error = read_pnpm_graph( + directory.path(), + &program, + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap_err(); + assert!(error.contains("Ignoring unsupported lockfile")); + assert!(error.contains("unverified")); +} + +#[cfg(unix)] +#[tokio::test] +async fn pnpm_list_command_failures_and_timeouts_are_not_successes() { + let directory = tempfile::tempdir().unwrap(); + let program = fake_pnpm(directory.path(), "printf 'lock parse failed' >&2\nexit 7"); + let error = read_pnpm_graph( + directory.path(), + &program, + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap_err(); + assert!(error.contains("lock parse failed")); + assert!(error.contains('7')); + + let program = fake_pnpm(directory.path(), "exec sleep 2"); + let error = read_pnpm_graph( + directory.path(), + &program, + Instant::now() + Duration::from_millis(20), + ) + .await + .unwrap_err(); + assert!(error.contains("timed out")); +} diff --git a/src/orchestrator/lock_age_tests.rs b/src/orchestrator/lock_age_tests.rs index 72b7ddb..ffcf5f8 100644 --- a/src/orchestrator/lock_age_tests.rs +++ b/src/orchestrator/lock_age_tests.rs @@ -5,6 +5,47 @@ use crate::test_support::local_registry::{LocalRegistry, TestProject}; use chrono::{DateTime, TimeZone, Utc}; use clap::Parser; +#[tokio::test] +async fn unchanged_lock_detects_a_previously_incomplete_rollback() { + let registry = LocalRegistry::new(); + for version in ["1.0.0", "1.0.1"] { + registry.publish("library", version, &[]); + } + let project = TestProject::new(®istry, &manifest("library = '1.0.0'\n")); + project.cargo_ok(&["generate-lockfile", "--offline"]); + let baseline = parse_registry_entries(&project.read("Cargo.lock")); + assert_eq!(project.locked_versions("library"), ["1.0.1"]); + let dates = FakeCratesIo::new() + .release("library", "1.0.0", old()) + .release("library", "1.0.1", fresh()); + let orchestrator = Orchestrator::new(CliArgs::parse_from(["depup"])).unwrap(); + let cargo = cargo_for(&project); + let result = orchestrator + .audit_lock_age( + &LockAgeAudit { + project_dir: project.path(), + cutoff: cutoff(), + exemptions: &Default::default(), + baseline: &baseline, + preferred: &PreferredVersions::new(), + adapter: &dates, + cargo: &cargo, + budget: LOCK_AGE_AUDIT_BUDGET, + }, + None, + ) + .await; + assert_eq!(project.locked_versions("library"), ["1.0.1"]); + assert!(result.has_unresolved(), "{result:?}"); + let failure = adjustment(&result, "library"); + assert_eq!(failure.to, None); + assert_eq!(failure.target, None); + assert!( + matches!(&failure.status, LockAgeStatus::NotAttempted(reason) if reason.contains("rollback minimum")) + ); + assert!(lock_is_accepted(&project)); +} + #[tokio::test] async fn a_release_absent_from_the_check_cache_is_refetched_and_rolled_back() { let registry = LocalRegistry::new(); @@ -79,7 +120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" LockAgeStatus::ReleaseDateUnavailable ); assert!(result.has_unresolved()); - assert_eq!(dates.fetch_count(), 2); + assert_eq!(dates.fetch_count(), 3); } #[tokio::test] @@ -567,9 +608,15 @@ async fn test_crate_returns_to_young_version_locked_before_install() { let result = audit(&project, &baseline, &dates).await; assert_eq!(project.locked_versions("solo"), vec!["1.0.1"]); - let solo = adjustment(&result, "solo"); + let solo = result + .adjustments + .iter() + .find(|item| item.name == "solo" && item.from == "1.0.2") + .unwrap(); assert_eq!(solo.status, LockAgeStatus::Restored); assert_eq!(solo.to.as_deref(), Some("1.0.1")); + assert_eq!(result.adjustments.len(), 1, "{result:?}"); + assert!(result.has_unresolved()); assert!(lock_is_accepted(&project)); } @@ -850,7 +897,168 @@ async fn test_exhausted_budget_leaves_everything_unchecked() { .await; assert_eq!(result.unchecked, 3); - assert!(result.adjustments.is_empty()); + assert_eq!(result.adjustments.len(), 3); + assert!( + result + .adjustments + .iter() + .all(|adjustment| adjustment.status.is_unverified()) + ); assert_eq!(project.read("Cargo.lock"), before); assert_eq!(dates.fetch_count(), 0); } + +/// 差し戻しの照会が予算切れになっても、全件検証には独立した枠がある。 +#[tokio::test] +async fn final_verification_has_an_independent_time_budget() { + use std::sync::atomic::{AtomicUsize, Ordering}; + struct DelayedFirst(AtomicUsize); + #[async_trait::async_trait] + impl crate::registry::RegistryAdapter for DelayedFirst { + fn language(&self) -> Language { + Language::Rust + } + fn registry_name(&self) -> &'static str { + "delayed-first" + } + async fn fetch_versions( + &self, + _: &str, + ) -> Result, crate::error::RegistryError> { + if self.0.fetch_add(1, Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_secs(1)).await; + } + Ok(vec![VersionInfo::new("1.0.0", old())]) + } + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.lock"), "version = 4\n[[package]]\nname = 'library'\nversion = '1.0.0'\nsource = 'registry+https://github.com/rust-lang/crates.io-index'\n").unwrap(); + let dates = DelayedFirst(AtomicUsize::new(0)); + let orchestrator = Orchestrator::new(CliArgs::parse_from(["depup"])).unwrap(); + let result = orchestrator + .audit_lock_age( + &LockAgeAudit { + project_dir: dir.path(), + cutoff: cutoff(), + exemptions: &Default::default(), + baseline: &Default::default(), + preferred: &Default::default(), + adapter: &dates, + cargo: &CargoCommand::new(), + budget: Duration::from_millis(100), + }, + None, + ) + .await; + assert_eq!(dates.0.load(Ordering::SeqCst), 2); + assert!(!result.has_unresolved(), "{result:?}"); +} + +/// 候補から除外された lock 版を、公開日専用の取得経路で検証する。 +#[tokio::test] +async fn candidate_filtered_locked_version_can_still_be_verified() { + struct LockedOnly; + #[async_trait::async_trait] + impl crate::registry::RegistryAdapter for LockedOnly { + fn language(&self) -> Language { + Language::Rust + } + fn registry_name(&self) -> &'static str { + "locked-only" + } + async fn fetch_versions( + &self, + _: &str, + ) -> Result, crate::error::RegistryError> { + Ok(vec![VersionInfo::new("1.0.0", old())]) + } + async fn fetch_locked_versions( + &self, + _: &str, + _: &[String], + _: Option>, + ) -> Result, crate::error::RegistryError> { + Ok(vec![VersionInfo::new("1.0.1", old())]) + } + } + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.lock"), "version = 4\n[[package]]\nname = 'library'\nversion = '1.0.1'\nsource = 'registry+https://github.com/rust-lang/crates.io-index'\n").unwrap(); + let orchestrator = Orchestrator::new(CliArgs::parse_from(["depup"])).unwrap(); + let result = orchestrator + .audit_lock_age( + &LockAgeAudit { + project_dir: dir.path(), + cutoff: cutoff(), + exemptions: &Default::default(), + baseline: &Default::default(), + preferred: &Default::default(), + adapter: &LockedOnly, + cargo: &CargoCommand::new(), + budget: LOCK_AGE_AUDIT_BUDGET, + }, + None, + ) + .await; + assert!(!result.has_unresolved(), "{result:?}"); +} + +#[tokio::test] +async fn full_lock_verification_fetches_in_bounded_parallel_batches() { + use std::sync::atomic::{AtomicUsize, Ordering}; + struct ConcurrentDates { + active: AtomicUsize, + peak: AtomicUsize, + count: AtomicUsize, + } + #[async_trait::async_trait] + impl crate::registry::RegistryAdapter for ConcurrentDates { + fn language(&self) -> Language { + Language::Rust + } + fn registry_name(&self) -> &'static str { + "concurrent-dates" + } + async fn fetch_versions( + &self, + _: &str, + ) -> Result, crate::error::RegistryError> { + self.count.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.peak.fetch_max(active, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(1)).await; + self.active.fetch_sub(1, Ordering::SeqCst); + Ok(vec![VersionInfo::new("1.0.0", old())]) + } + } + let dir = tempfile::tempdir().unwrap(); + let mut content = "version = 4\n".to_string(); + for n in 0..64 { + content.push_str(&format!("[[package]]\nname = 'library-{n}'\nversion = '1.0.0'\nsource = 'registry+https://github.com/rust-lang/crates.io-index'\n")); + } + std::fs::write(dir.path().join("Cargo.lock"), &content).unwrap(); + let baseline = parse_registry_entries(&content); + let dates = ConcurrentDates { + active: AtomicUsize::new(0), + peak: AtomicUsize::new(0), + count: AtomicUsize::new(0), + }; + let orchestrator = Orchestrator::new(CliArgs::parse_from(["depup"])).unwrap(); + let result = orchestrator + .audit_lock_age( + &LockAgeAudit { + project_dir: dir.path(), + cutoff: cutoff(), + exemptions: &Default::default(), + baseline: &baseline, + preferred: &Default::default(), + adapter: &dates, + cargo: &CargoCommand::new(), + budget: LOCK_AGE_AUDIT_BUDGET, + }, + None, + ) + .await; + assert!(!result.has_unresolved(), "{result:?}"); + assert_eq!(dates.count.load(Ordering::SeqCst), 64); + assert!((2..=8).contains(&dates.peak.load(Ordering::SeqCst))); +} diff --git a/src/package_manager.rs b/src/package_manager.rs index 279cf1a..ffd026e 100644 --- a/src/package_manager.rs +++ b/src/package_manager.rs @@ -123,7 +123,9 @@ impl SystemPackageManager { if working_dir.join("bun.lock").exists() || working_dir.join("bun.lockb").exists() { return Some("bun"); } - if working_dir.join("package-lock.json").exists() { + if working_dir.join("npm-shrinkwrap.json").exists() + || working_dir.join("package-lock.json").exists() + { return Some("npm"); } // package.json があるがロックファイルがない場合は npm をデフォルトにする @@ -213,11 +215,10 @@ impl SystemPackageManager { /// パッケージマネージャ実行時に追加する環境変数を決定する。 /// - /// - **pnpm (v10.16+)**: `npm_config_minimum_release_age=<分>` で - /// `minimumReleaseAge` 設定を注入する。pnpm は npm の config 規約に - /// 従うため、この env var は `.npmrc` の `minimum-release-age=<分>` と - /// 等価になる。pnpm v10.16 未満ではこの env var は未知の設定として - /// 無視される (graceful no-op)。 + /// - **pnpm**: `npm_config_minimum_release_age=<分>` と + /// `pnpm_config_minimum_release_age=<分>` の両方で設定を注入する。 + /// npm 互換の設定を読む版と pnpm 独自の設定を読む版の両方へ同じ値を渡す。 + /// 未対応の版や既存 lock にも備え、install 後に実際の版を監査する。 /// - **uv (preview)**: `UV_MALWARE_CHECK=1` を常に注入し、`uv sync` /// 実行時に OSV の MAL advisories と locked resolution を照合する /// ライトウェイトなマルウェアチェックを有効化する (Astral の preview @@ -240,6 +241,10 @@ impl SystemPackageManager { "npm_config_minimum_release_age".to_string(), minutes.to_string(), )); + env.push(( + "pnpm_config_minimum_release_age".to_string(), + minutes.to_string(), + )); } if let Some(age) = min_age && pm == "mise" @@ -676,10 +681,16 @@ mod tests { let env = pm.get_install_env("pnpm", Some(age)); assert_eq!( env, - vec![( - "npm_config_minimum_release_age".to_string(), - "20160".to_string() - )] + vec![ + ( + "npm_config_minimum_release_age".to_string(), + "20160".to_string() + ), + ( + "pnpm_config_minimum_release_age".to_string(), + "20160".to_string() + ), + ] ); } @@ -690,6 +701,15 @@ mod tests { assert!(env.is_empty()); } + #[test] + fn pnpm_age_supports_both_config_environments() { + let env = SystemPackageManager::new() + .get_install_env("pnpm", Some(Duration::from_secs(14 * 86400))); + for prefix in ["npm_config", "pnpm_config"] { + assert!(env.contains(&(format!("{prefix}_minimum_release_age"), "20160".into()))); + } + } + #[test] fn pnpm_age_rounds_up_without_weakening_seconds() { let pm = SystemPackageManager::new(); @@ -702,7 +722,10 @@ mod tests { ] { assert_eq!( pm.get_install_env("pnpm", Some(age)), - vec![("npm_config_minimum_release_age".into(), minutes.into())] + vec![ + ("npm_config_minimum_release_age".into(), minutes.into()), + ("pnpm_config_minimum_release_age".into(), minutes.into()), + ] ); } } @@ -871,6 +894,14 @@ mod tests { assert_eq!(pm.detect_node_pm(temp_dir.path()), Some("npm")); } + #[test] + fn test_detect_node_pm_npm_shrinkwrap() { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::write(temp_dir.path().join("npm-shrinkwrap.json"), "{}").unwrap(); + let pm = SystemPackageManager::new(); + assert_eq!(pm.detect_node_pm(temp_dir.path()), Some("npm")); + } + #[test] fn test_detect_node_pm_yarn() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/registry/crates_io.rs b/src/registry/crates_io.rs index 1520cd4..7ba97cd 100644 --- a/src/registry/crates_io.rs +++ b/src/registry/crates_io.rs @@ -66,6 +66,60 @@ struct CratesIoResponse { versions: Vec, } +impl CratesIoResponse { + fn into_versions(self, include_yanked: bool) -> Vec { + let mut versions = Vec::new(); + for version in self.versions { + if version.yanked && !include_yanked { + continue; + } + if let Ok(released_at) = version.created_at.parse::>() { + let mut info = VersionInfo::new(&version.num, released_at); + info.publisher = version.publisher(); + versions.push(info); + } + } + versions.sort(); + versions + } +} + +/// Sparse index の公開日は yank によって変わらない。 +#[derive(Deserialize)] +struct IndexRelease { + name: String, + vers: String, + #[serde(default)] + pubtime: Option, +} + +fn index_path(name: &str) -> String { + let name = name.to_ascii_lowercase(); + match name.len() { + 1 => format!("1/{name}"), + 2 => format!("2/{name}"), + 3 => format!("3/{}/{name}", &name[..1]), + _ => format!("{}/{}/{name}", &name[..2], &name[2..4]), + } +} + +fn index_release_dates(content: &str, package: &str) -> Option> { + let mut versions = Vec::new(); + for line in content.lines() { + let release: IndexRelease = serde_json::from_str(line).ok()?; + if !release.name.eq_ignore_ascii_case(package) { + return None; + } + if let Some(date) = release + .pubtime + .and_then(|date| date.parse::>().ok()) + { + versions.push(VersionInfo::new(release.vers, date)); + } + } + Some(versions) +} + /// クレートバージョン情報 #[derive(Debug, Deserialize)] struct CrateVersion { @@ -173,6 +227,24 @@ impl CratesIoAdapter { permit } + + async fn api_versions( + &self, + crate_name: &str, + include_yanked: bool, + ) -> Result, RegistryError> { + self.validate_crate_name(crate_name)?; + let _permit = self.apply_rate_limit().await; + let response: CratesIoResponse = self + .client + .get_json( + &self.build_url(crate_name), + crate_name, + self.registry_name(), + ) + .await?; + Ok(response.into_versions(include_yanked)) + } } #[async_trait] @@ -186,37 +258,37 @@ impl RegistryAdapter for CratesIoAdapter { } async fn fetch_versions(&self, crate_name: &str) -> Result, RegistryError> { - // 名前の検証はレート制限を取る前に行う (不正名で 1 秒の枠を消費しない) - self.validate_crate_name(crate_name)?; - - // レート制限を適用(HTTP リクエスト完了まで許可を保持する) - let _permit = self.apply_rate_limit().await; + self.api_versions(crate_name, false).await + } - let url = self.build_url(crate_name); - let response: CratesIoResponse = self + async fn fetch_locked_versions( + &self, + crate_name: &str, + locked: &[String], + publisher_cutoff: Option>, + ) -> Result, RegistryError> { + self.validate_crate_name(crate_name)?; + let url = format!("https://index.crates.io/{}", index_path(crate_name)); + if let Ok(content) = self .client - .get_json(&url, crate_name, self.registry_name()) - .await?; - - let mut versions = Vec::new(); - - for version in response.versions { - // yank されたバージョンをスキップ - if version.yanked { - continue; - } - - if let Ok(released_at) = version.created_at.parse::>() { - let mut info = VersionInfo::new(&version.num, released_at); - info.publisher = version.publisher(); - versions.push(info); - } + .get_text(&url, crate_name, self.registry_name()) + .await + && let Some(versions) = index_release_dates(&content, crate_name) + && locked + .iter() + .all(|locked| versions.iter().any(|info| info.version == *locked)) + && publisher_cutoff.is_none_or(|cutoff| { + locked.iter().all(|locked| { + versions + .iter() + .any(|info| info.version == *locked && info.released_at <= cutoff) + }) + }) + { + return Ok(versions); } - - // バージョンでソート - versions.sort(); - - Ok(versions) + // 古い index や若い版の公開者免除では API を使う。yank 済みの lock 版も含む。 + self.api_versions(crate_name, true).await } } @@ -224,6 +296,45 @@ impl RegistryAdapter for CratesIoAdapter { mod tests { use super::*; + #[test] + fn locked_metadata_includes_yanked_dates_without_making_them_candidates() { + let response = || { + serde_json::from_value::(serde_json::json!({"versions":[ + {"num":"1.0.0","created_at":"2026-01-01T00:00:00Z","yanked":false}, + {"num":"1.0.1","created_at":"2026-01-02T00:00:00Z","yanked":true} + ]})) + .unwrap() + }; + assert_eq!(response().into_versions(false).len(), 1); + let locked = response().into_versions(true); + assert_eq!(locked.len(), 2); + assert_eq!(locked[1].version, "1.0.1"); + assert_eq!( + locked[1].released_at, + "2026-01-02T00:00:00Z".parse::>().unwrap() + ); + } + + #[test] + fn sparse_publication_dates_include_yanked_and_skip_missing_dates() { + let content = r#"{"name":"library","vers":"1.0.0","pubtime":"2026-01-01T00:00:00Z","yanked":true} +{"name":"library","vers":"1.0.1","pubtime":null} +{"name":"library","vers":"1.0.2","pubtime":"invalid"}"#; + let dates = index_release_dates(content, "library").unwrap(); + assert_eq!(dates.len(), 1); + assert_eq!(dates[0].version, "1.0.0"); + assert!(index_release_dates(content, "other").is_none()); + assert!(index_release_dates("invalid", "library").is_none()); + for (name, path) in [ + ("X", "1/x"), + ("ab", "2/ab"), + ("ABC", "3/a/abc"), + ("serde", "se/rd/serde"), + ] { + assert_eq!(index_path(name), path); + } + } + #[test] fn publisher_requires_registry_verified_identity() { use crate::update::PublisherEvidence; diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 3745c07..1cac56b 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -50,6 +50,17 @@ pub trait RegistryAdapter: Send + Sync { /// パッケージの利用可能なバージョンを取得 async fn fetch_versions(&self, package: &str) -> Result, RegistryError>; + + /// lock に存在する版の公開日。候補と違い、yank 済みの版も検証できる必要がある。 + /// publisher_cutoff より若い版だけ、レジストリが確認した公開者も取得する。 + async fn fetch_locked_versions( + &self, + package: &str, + _locked: &[String], + _publisher_cutoff: Option>, + ) -> Result, RegistryError> { + self.fetch_versions(package).await + } } /// Maven 座標 (groupId / artifactId) と GitHub owner/repo セグメントの共通文字種検証。 diff --git a/src/registry/npm.rs b/src/registry/npm.rs index 7644170..91175eb 100644 --- a/src/registry/npm.rs +++ b/src/registry/npm.rs @@ -35,6 +35,25 @@ struct NpmPackageResponse { versions: HashMap, } +/// lock の監査では latest や deprecated に関わらず、固定された版の公開日を読む。 +#[derive(Debug, Deserialize)] +struct NpmPublicationResponse { + #[serde(default)] + time: HashMap, +} + +impl NpmPublicationResponse { + fn into_publication_dates(self) -> HashMap> { + self.time + .into_iter() + .filter_map(|(version, value)| { + let released_at = value.as_str()?.parse().ok()?; + Some((version, released_at)) + }) + .collect() + } +} + /// npm の各版のメタデータ。未知フィールドは serde が読み捨てる。 #[derive(Debug, Deserialize)] struct NpmVersionMetadata { @@ -99,6 +118,19 @@ impl NpmAdapter { Self { client } } + /// 更新候補の除外規則を通さず、インストール済みの版を照合する公開日を取得する。 + pub async fn fetch_publication_dates( + &self, + package: &str, + ) -> Result>, RegistryError> { + self.validate_package_name(package)?; + let response: NpmPublicationResponse = self + .client + .get_json(&self.build_url(package), package, self.registry_name()) + .await?; + Ok(response.into_publication_dates()) + } + /// パッケージ用の URL を構築 fn build_url(&self, package: &str) -> String { format!("{}/{}", NPM_REGISTRY_URL, package) @@ -193,6 +225,32 @@ impl RegistryAdapter for NpmAdapter { mod tests { use super::*; + #[test] + fn lock_audit_dates_include_deprecated_and_above_latest_versions() { + let response: NpmPublicationResponse = serde_json::from_value(serde_json::json!({ + "dist-tags": {"latest": "1.0.0"}, + "versions": { + "1.0.0": {}, + "1.1.0": {"deprecated": "use another release"}, + "2.0.0": {} + }, + "time": { + "1.0.0": "2026-09-01T00:00:00Z", + "1.1.0": "2026-09-30T00:00:00Z", + "2.0.0": "2026-10-01T00:00:00Z", + "invalid": "not-a-date", + "wrong-type": 12 + } + })) + .unwrap(); + let dates = response.into_publication_dates(); + assert_eq!(dates.len(), 3); + assert!(dates.contains_key("1.1.0")); + assert!(dates.contains_key("2.0.0")); + assert!(!dates.contains_key("invalid")); + assert!(!dates.contains_key("wrong-type")); + } + #[test] fn test_npm_adapter_language() { let client = HttpClient::new().unwrap(); diff --git a/tests/e2e_tests.rs b/tests/e2e_tests.rs index 99d0c5a..526183d 100644 --- a/tests/e2e_tests.rs +++ b/tests/e2e_tests.rs @@ -323,6 +323,57 @@ mod json_output_tests { mod exit_code_tests { use super::*; + #[test] + fn unchanged_install_cannot_pass_with_an_unverified_lock() { + for (filename, content, lock) in [ + ( + "Cargo.toml", + "[package]\nname = 'example'\nversion = '1.0.0'\n", + "Cargo.lock", + ), + ( + "package.json", + r#"{"name":"example","dependencies":{}}"#, + "package-lock.json", + ), + ] { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(filename), content).unwrap(); + // lock のない無変更プロジェクトは install を起動せず正常終了する。 + let no_lock = Command::new(get_binary_path()) + .args(["--install", "--age", "2w", "--no-osv", "--quiet"]) + .arg(dir.path()) + .output() + .unwrap(); + assert!(no_lock.status.success(), "{filename}: {no_lock:?}"); + fs::write(dir.path().join(lock), "invalid lock").unwrap(); + let output = Command::new(get_binary_path()) + .args(["--install", "--age", "2w", "--no-osv", "--quiet"]) + .arg(dir.path()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{filename}: {output:?}"); + assert!(String::from_utf8_lossy(&output.stderr).contains(lock)); + assert_eq!( + fs::read_to_string(dir.path().join(filename)).unwrap(), + content + ); + assert_eq!( + fs::read_to_string(dir.path().join(lock)).unwrap(), + "invalid lock" + ); + + for flag in ["--dry-run", "--no-age"] { + let output = Command::new(get_binary_path()) + .args(["--install", "--no-osv", "--quiet", flag]) + .arg(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{filename} {flag}: {output:?}"); + } + } + } + /// 更新なしの正常実行で終了コードを確認する #[test] fn test_exit_code_no_updates() { From f74cd03bb556b5525f795fad5326b26dcdbf7ca1 Mon Sep 17 00:00:00 2001 From: owa <20108042+owayo@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:57:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20lock=20=E5=85=A8=E4=BB=B6=E3=81=AE?= =?UTF-8?q?=20age=20=E6=A4=9C=E8=A8=BC=E3=81=A8=E6=9C=AA=E8=A7=A3=E6=B6=88?= =?UTF-8?q?=E6=99=82=E3=81=AE=E7=B5=82=E4=BA=86=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=92=E8=AA=AC=E6=98=8E=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 無変更の再実行と Node の推移依存を含む監査対象、対応する lock 形式、失敗時の表示を英語と日本語で説明する。 Rust の差し戻しと最終検証の独立した時間枠も開発指示へ反映する。 --- AGENTS.md | 8 +++---- docs/cli-reference.ja.md | 2 +- docs/cli-reference.md | 2 +- docs/usage.ja.md | 49 +++++++++++++++++++++++++++------------- docs/usage.md | 45 ++++++++++++++++++++++++------------ 5 files changed, 70 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 927933d..bafd274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,14 +233,14 @@ make ci # CI と同じ検査 (fmt-check + lint + test、書き換 - `--only` が指定されている場合は `--exclude` より優先される。Cargo リネーム依存では実パッケージ名・マニフェスト名のどちらで指定しても同じ優先順位で判定する - `.depup` にルートとサブディレクトリが両方含まれる場合、`--install` と Rust の `--age --install` 後処理は、更新されたマニフェストに最も近い(最も深い)対象ディレクトリで実行する - age が有効な場合 (CLI `--age` / プロジェクト `minimumReleaseAge` / グローバル設定 / 組み込みデフォルト 1w のいずれか) の transitive 依存への適用方法は PM ごとに異なる。install フェーズも judge と同じ解決済み age を使うため、CLI `--age` を明示しなくてもプロジェクト設定やデフォルト 1w が transitive へ反映される: - - **Rust (cargo)**: `cargo update` 後に Cargo.lock を走査し、age 違反の crate を差し戻す (post-install audit。対象・差し戻し先・差し戻し方・表示は下の小項目)。cargo の再解決で連鎖する新たな違反に備えて最大 5 回まで反復する。**監査対象は install 前後で lock が変わったエントリだけに絞る** (`changed_entries`) — install 前の Cargo.lock を控えておき、同じバージョンのまま残っているものは depup が入れたものではないので照会しない。crates.io は crawler policy により 1 リクエスト/秒へ直列化されるため、lock 全体 (depup 自身なら 249 件) を舐めると監査だけで 4〜9 分かかり、進捗表示のない無音待ちがハングと区別できなかった (実測で監査対象 249 件 → 24 件)。install 前に Cargo.lock が無ければ baseline は空 = 全エントリが新規なので従来どおり全件監査になる。安全弁として監査には `LOCK_AGE_AUDIT_BUDGET` (180 秒) の時間予算があり、Cargo.lock の有無に関係なく常に効く (タイマーは lock ディレクトリごとに監査開始時から数えるので、lock が複数あれば各 180 秒)。超過時は監査済みの差し戻しを保持したまま打ち切り、未検証件数を stderr に警告する (exit code には影響しない)。install が 1 件でも失敗した場合、監査自体を実行しない。差し戻しで起動する `cargo update -p --precise` は 1 回 `CARGO_UPDATE_TIMEOUT` (120 秒) で打ち切る (`kill_on_drop` 付き) + - **Rust (cargo)**: install で変わった crates.io の版は、元の要件と利用可能な install 前の下限が許す範囲で差し戻す。最大 5 回の反復とまとめ解きに 180 秒の枠を与える。その後、独立した 180 秒の読み取り専用検証で最終 Cargo.lock の全版を監査する。公開日は公式 sparse index から最大 8 件並列で取得し、公開日がない場合や公開者免除の検証には 1 リクエスト/秒の API を使う。公開日専用の取得では yank 済みの lock 版も含めるが、候補としては引き続き除外する。無変更の既存 lock も検証し、違反・未確認・時間切れは exit 2(install 失敗もあれば exit 1)。lock がなく install も不要な実行では監査しない。cargo の各差し戻しは最大 120 秒、kill_on_drop 付きで起動する。 - **監査するのは crates.io の crate だけ**。私設 registry の crate は crates.io の公開日で判定できないので対象外にする。以前は私設 registry の crate も同名の crates.io の crate の公開日で照会しており、別物の公開日で差し戻すかどうかを決めていた - **差し戻し先**: depup がこの実行で更新した直接依存は、画面に出した更新先の版 (age を満たし、OSV の脆弱性チェックと `--max-change` も通った版) を優先する (`rollback_target` / `PreferredVersions`)。同じ semver 系列で今の版より古く age を満たし、install 前の lock にあった同じ系列の版以上のものに限る (manifest の版要求が lock より古いまま `--max-change` で古い版を選んだ場合に、install 前より古い版まで下げて既存コードが使っていた API を消さないため)。それ以外は、同じ semver 系列で age を満たす安定版 (プレリリースを除く) のうち今の版より古い最新 (`pick_older_within_age`)。judge の版を優先しないと、OSV で退けた版を差し戻しで選び直してしまう。どちらの場合も install 前の lock の同系列の利用可能な版 (`version_floor`) より古くはせず、age を満たす版がそれより古いものしか無ければ install 前の版へ戻す (install で入った変更だけを取り消す)。crates.io adapter が yank 済み・公開日不明として取得一覧から外した版は、下限にも戻し先にも使わない。下限が無効なら同じ semver 系列の有効な age 条件内の版を選び、候補が無ければ `NoOlderCandidate` として未解消を報告する。戻した install 前の版も期間内なら `LockAgeStatus::Restored` とし、「satisfy --age」の一覧とは別の行 (`returned to the version locked before the install, which is also newer than --age`) で黄色く出す (満たしたと読ませない)。まとめ解きでも同じ下限を `RollbackCandidate::minimum` として範囲固定の下端と結果の検査に使う - **1 件ずつの `--precise` が衝突する仕組み**: `cc` / `syn` のように 1 件ずつで済む crate は、従来どおり `cargo update -p @ --precise ` で差し戻す。ただし `--precise` は対象の依存元 (対象に依存している crate) を lock の版に据え置く。互いを `=` で固定し合う crate 一族 (wasm-bindgen / js-sys / web-sys / wasm-bindgen-futures / wasm-bindgen-test など) では、据え置かれた依存元の `=` 要件が対象の古い版を許さない。一族の中に依存元を持たない crate が 1 つだけなら、それを動かせば依存先も連れて動く (`-p` で指定した crate の依存先は、必要なら更新される) が、2 つ以上あると互いを据え置き合い、どれから試しても全件が衝突して失敗する。以前はこの 1 件ずつの差し戻ししか無く、失敗は `--verbose` のときにしか出さなかったため、既定の出力には差し戻せた分だけが並んで `--age` が効いたように見えていた - **まとめ解き** (`cargo_rollback/batch.rs`): 1 件ずつの差し戻しで衝突した crate は、まとめて解き直す。利用者の Cargo.toml は一切書き換えない。workspace の依存解決に必要な部分 (root と member の Cargo.toml、空の target ファイル、Cargo.lock) だけを一時ディレクトリに写し (`scratch.rs`)、その写しに一時的な固定用の別名依存 (`depup-age-pin-N = { package = ..., version = ..., default-features = false }`) を足して、対象の crate を `-p @` に並べてまとめて `cargo update` する。直接依存でない crate も固定できるように、既存の依存の版要求を書き換えるのではなく別名の依存を足す (推移依存だけの一族も救える)。対象は最初から lock の依存辺でつながる組 (連結成分) に分け、大きい組から解く (辺の無い組どうしは互いの固定に縛られないので、まとめて試す利点が無く、解けない 1 件が一族を巻き添えにして予算も食うだけになる。予算が尽きたときに戻す crate の多い一族を先に済ませておける)。組ごとに試す順は「全員を `=目標` で固定 → 一族の頂点 (一族内に依存元を持たない crate) だけを `=` で固定 → 全員を同じ系列で目標以下 (`>=max(系列の下限, install 前の版), <=目標`) に固定」。範囲固定は judge が一族の 1 つだけ古い版を選んだ場合など目標同士が噛み合わないときのためで、選ばれた版は次のパスで公開日を監査し直す。Cargo.toml の直接の版要求が目標を許さない crate は、まとめ解きの前に外して `BlockedByManifest` として報告する (解けない 1 件が一族のまとめ解きを巻き添えにしない)。時間切れは cargo の失敗と型で区別し (`CargoError::TimedOut` / `RunError::OutOfTime`)、別の手を試し続けずに残りを `NotAttempted` にする。元の Cargo.lock を置き換える前にも検証の分の時間が残っているかを確かめる (置き換えてから時間切れになると、戻すだけの無駄になり、理由も「Cargo.toml が受け入れない」と取り違える)。まとめ解きが 1 件も戻せなかったときは、lock の内容と候補の集合の組 (`together_fingerprint`) を覚えておき、同じ条件では試し直さない (lock だけを鍵にすると、新しい候補が加わった組を試さず、無関係な差し戻しのたびに同じ組を丸ごと試し直していた)。固定を外して lock を整えてから元の Cargo.lock を置き換え、元の Cargo.toml がその lock をそのまま受け入れること (`cargo update --workspace --locked`) を確かめる。受け入れられなければ、置き換える前の Cargo.lock に戻す。作業中に元の Cargo.toml / Cargo.lock が書き換わったら、そちらを優先して手を引く。cargo は元のディレクトリで起動する — cargo の設定ファイル (`.cargo/config.toml`) も rustup の toolchain 指定 (`rust-toolchain.toml` 等) も起動ディレクトリから上へたどって探すので、一時ディレクトリで起動すると、元のプロジェクトの source replacement や toolchain の固定が効かないまま解いてしまう。まとめ解きで新しく Cargo.lock に入った crate も同じく監査して差し戻し、lock から外れた crate は ` → removed` と表示して差し戻せた件数に含める - - **差し戻しの成否は lock を読み直して判定する**: `cargo update --precise` の exit 0 だけで成功にしない (directory source への置き換えでは `--precise` が何もせず exit 0 になり、以前は「差し戻した」と誤報告したうえ試行済みとして二度と扱わなかった)。成功後に lock を読み直して今の版が外れたことを確かめ、外れていなければ失敗としてまとめ解きへ回す。監査の最後には `AdjustmentLog::reconcile_with_lock` で「差し戻した」記録を最終の lock と突き合わせ (元の版が残っていれば失敗に直し、後の差し戻しでさらに動いた行き先に合わせる)、最終の lock に残る install で変わった版を、監査中に取得した公開日 (キャッシュのみ。通信しない) で判定し直す。公開日を取得していない版は未検証 (`unchecked`) として数える。まとめ解きの検証に失敗して元の Cargo.lock を戻せなかったときは、元の内容を OS の一時ディレクトリへ退避し、その場所を `problems` として `--verbose` なしで表示する。確認から書き戻しまでの間に他のプロセスが Cargo.lock を書いた変更までは守れない (cargo 自身のコマンドも Cargo.lock を排他せずに書くので同じ条件) + - **差し戻しの成否は lock を読み直して判定する**: cargo update の exit 0 だけで成功にせず、元の版が lock から外れたことを確かめる。最終照合は移動先の公開日を含めて全件を確認する。install 前の若い版へ戻した場合は Restored として exit 2 にし、移動先を別件で重複報告しない。実際の移動先(to)と要求した差し戻し先(target)は別に保存する。元の lock が読めなければ自動差し戻しを無効にする。最終検証中に lock が変わった場合も未確認として再実行を促す。まとめ解き後に元の Cargo.lock を戻せなければ内容を一時ファイルに退避し、通常出力で場所を知らせる。 - **反復上限の後の検証だけのパス**: 反復の上限 (5 回) を使い切った後に、照会だけして差し戻さないパスを 1 回走らせ、残っている違反を `not attempted: <理由>` として差し戻せなかった件数に数える。以前は上限に達するとそのまま抜けていたため、最後の差し戻しで lock に入った版は照会されず、違反が残っても何も表示されなかった - - **表示**: 差し戻せなかった件数は `--verbose` が無くても必ず stderr に出す (`N crate(s) could not be rolled back to satisfy --age (use --verbose for details)`)。理由の一覧 (`Cargo.toml requires ...` / `no older version satisfies --age` / `cargo update failed: ...` / `not attempted: ...` / `release date unavailable`) は `--verbose` のときだけ出す。公開日を取得できなかった crate は、差し戻せなかった件数と分けて別の行で数える (`could not be checked against --age: release date unavailable`)。見出しは直接依存も含むので `transitive dep(s)` から `crate(s)` に変えた。`--verbose` の見出しは `Enforcing --age on crates changed in Cargo.lock...`、進捗バーの開始時のラベルは `Auditing Cargo.lock against --age` (監査中は従来どおり照会中・差し戻し中の crate 名に変わり、まとめ解きの間は `Resolving N crate(s) together`)。監査の結果で exit code は変わらない (従来どおり) + - **表示**: 差し戻し結果・未解消・公開日不明・時間切れを常に stderr に表示する。失敗は名前、元の lock 版、試行時に要求した版、Cargo のエラーを含む理由を通常出力でも表示する。verbose の見出しは Enforcing --age on registry crates in Cargo.lock...。監査未完了を成功扱いしない。 - **表示と lock の食い違いの注記** (`cargo_rollback/report.rs`): `--install` の後 (Rust のみ)、画面に出した更新先と Cargo.lock に入った版が違えば、黄色で注記する (` — N update(s) locked at a different version than shown:`)。`--verbose` は不要で、age が無効でも出す。Cargo.toml の要件が `^` (演算子なしの版指定も同じ) なら、`--max-change` や OSV で古い版を選んで書いても、`cargo update` はその要件を満たすより新しい版を入れうる。age 違反なら監査が差し戻すが、age を満たす版は差し戻しの対象にならないので、以前は報告と lock の食い違いが黙って残っていた - cargo 自身の `min-publish-age` (RFC 3923) は今回は使っていない。Rust 1.100 で stable 化の予定で、1.98 では `-Zmin-publish-age` を付けないと設定が無視される - **Node.js (pnpm v10.16+)**: `pnpm install` を `npm_config_minimum_release_age=<分>` 環境変数付きで起動する。pnpm は npm 互換の config 規約に従うため、この env var は `.npmrc` の `minimum-release-age=<分>` と等価に解釈される (公式ドキュメント: "This applies to all dependencies, including transitive ones")。pnpm v10.16 未満ではこの env var は未知設定として無視される (graceful no-op)。公式の CLI フラグは現時点で未実装 ([pnpm/pnpm#11224](https://github.com/pnpm/pnpm/issues/11224)) @@ -331,4 +331,4 @@ make ci # CI と同じ検査 (fmt-check + lint + test、書き換 - 公開者による 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 のみを外し、プロジェクトの明示的な最小公開期間には優先しない。不正な例外設定は他のグローバル設定を維持したまま無効化する。 - age は各マニフェストのディレクトリから実行ルート(メンバー内起動時は workspace ルート)まで継承し、最も厳しい明示値を使う。兄弟の設定は候補選択に混ぜない。共有 install / Cargo.lock は更新がないメンバーも含めて最厳値を採り、公開者の例外は全メンバーの共通部分だけを残す。候補選択・Tauri 同期・監査の基準時刻は実行開始時に固定し、uv / mise に絶対 cutoff、pnpm に切り上げた分数を渡す。 -- Cargo の age 監査は他言語の install が失敗しても行う。lock の読み込み・解析失敗、公開日不明、差し戻し失敗、時間切れが残ると exit 2(install 失敗もあれば exit 1)。既存 lock に戻した場合は変更の取り消しなので失敗にしない。キャッシュにない lock 版は一覧を再取得して確認し、未確認のまま成功扱いしない。 +- Cargo の age 監査は他言語の install が失敗しても行う。lock の読み込み・解析失敗、公開日不明、差し戻し失敗、時間切れが残ると exit 2(install 失敗もあれば exit 1)。既存 lock に戻しても最終版が age を満たさなければ exit 2。無変更の `--install` でも Rust / Node lock を監査し、失敗の名前・要求版・理由は通常出力に表示する。キャッシュにない lock 版は一覧を再取得して確認し、未確認のまま成功扱いしない。 diff --git a/docs/cli-reference.ja.md b/docs/cli-reference.ja.md index f6d8ac1..966d611 100644 --- a/docs/cli-reference.ja.md +++ b/docs/cli-reference.ja.md @@ -149,7 +149,7 @@ depup --diff |--------|------| | `0` | 失敗なし。更新がなかった場合、ドライラン、`Errors:` 欄の内容が OSV の通知(フォールバックや問い合わせの失敗)だけの場合も含む | | `1` | パッケージマネージャーの install が失敗した、`--cd` でディレクトリを移動できなかった、または depup 自体が動作を続けられなかった(HTTP クライアントの初期化や、結果の出力に失敗した場合など) | -| `2` | 処理の一部が失敗した。マニフェストの読み込み・解析・書き込みの失敗(書き込み先が曖昧なための拒否を含む)、レジストリからの取得の失敗、Cargo の age 監査で違反・未確認が残る場合(lock の読み込み失敗や時間切れを含む)が該当する。コマンドライン引数の誤りも `2` になる | +| `2` | 処理の一部が失敗した。マニフェストの読み込み・解析・書き込みの失敗(書き込み先が曖昧なための拒否を含む)、レジストリからの取得の失敗、Rust または Node の lock の age 監査で違反・未確認が残る場合(lock の読み込み失敗や時間切れを含む)が該当する。コマンドライン引数の誤りも `2` になる | 終了コード 2 になる失敗が起きても、処理は止まりません。取得に失敗した依存はスキップし、読み込みや解析に失敗したマニフェストは処理から外して、それ以外の書き込みや install は続けます。書き込みに失敗したファイルは元のまま残りますが、その依存は更新として表示され、`--install` も実行されます。`1` と `2` の両方に当てはまる場合は `1` になります。 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 74d92b1..5c4ae16 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -149,7 +149,7 @@ depup --diff |------|---------| | `0` | No failures. This includes runs with no updates, dry runs, and runs whose only `Errors:` entries are OSV notices (fallbacks or failed lookups). | | `1` | A package manager install failed, `--cd` could not change the directory, or depup itself could not continue (for example, the HTTP client failed to initialize or the results could not be written). | -| `2` | Part of the run failed: a manifest could not be read, parsed, or written (including a refused ambiguous write), a registry lookup failed, or a Cargo age audit left violations or unverified entries (including an unreadable lock or an exhausted time budget). Invalid command-line arguments also exit with `2`. | +| `2` | Part of the run failed: a manifest could not be read, parsed, or written (including a refused ambiguous write), a registry lookup failed, or a Rust or Node lockfile age audit left violations or unverified entries (including an unreadable lock or an exhausted time budget). Invalid command-line arguments also exit with `2`. | A code-2 failure does not stop the run. A failed lookup skips that dependency, and a manifest that cannot be read or parsed is left out; everything else is still written and installed. A failed write leaves that file unchanged, but its dependencies are still listed as updated and `--install` still runs for it. When both `1` and `2` apply, `1` wins. diff --git a/docs/usage.ja.md b/docs/usage.ja.md index 867e28e..ca335fd 100644 --- a/docs/usage.ja.md +++ b/docs/usage.ja.md @@ -332,21 +332,21 @@ depup が書き換えるのは、解析した依存宣言だけです。依存 `--install` を指定すると、depup はマニフェストを書き換えたあと、プロジェクトごとにパッケージマネージャーを実行します。ロックファイルやインストール済みのパッケージを、新しいバージョンに合わせるためです。 - install を実行するのは、1 件以上更新したマニフェストだけです。`--dry-run` のときは実行しません。 -- [`.depup`](configuration.ja.md#depup-設定ファイル) がない場合は、更新したマニフェストがワークスペースのメンバーのものでも、install はすべて対象ディレクトリ(`PATH` 引数、省略時はカレントディレクトリ)で実行します。`.depup` がある場合は、更新したマニフェストを含む対象ディレクトリのうち最も深いもので実行するため、入れ子のアプリは各自のディレクトリで install されます。 +- Node は pnpm workspace の共有ルート、またはマニフェストのあるディレクトリで install を実行します。他言語は [`.depup`](configuration.ja.md#depup-設定ファイル) がなければ対象ディレクトリ(PATH 引数)を使い、あれば更新したマニフェストを含む対象のうち最も深いディレクトリを使います。 - install は 1 つずつ、ディレクトリのパス順に実行します。同じディレクトリでは、言語ごとに 1 回だけです。パッケージマネージャーの出力は画面に流さずに depup が受け取り、標準エラー出力は install が失敗したときだけ表示します。 -install が失敗しても、残りの install は続けます。失敗したコマンドとパッケージマネージャーの標準エラー出力を表示し、最後に `Error: Some package manager installs failed` を出して終了コード 1 で終わります。パッケージマネージャーがインストールされていない場合も失敗として扱います。書き換え済みのマニフェストは元に戻しません。別の install が失敗しても、Rust の監査([Rust の `Cargo.lock` 監査](#rust-の-cargolock-監査))は実行します。 +install が失敗しても、残りの install は続けます。失敗したコマンドとパッケージマネージャーの標準エラー出力を表示し、最後に `Error: Some package manager installs failed` を出して終了コード 1 で終わります。パッケージマネージャーがインストールされていない場合も失敗として扱います。書き換え済みのマニフェストは元に戻しません。別の install が失敗しても、Rust と Node の lock の監査は実行します。更新がない `--install` の再実行でも既存 lock を監査しますが、更新がなければパッケージマネージャーは再実行しません。 ### パッケージマネージャーごとのコマンド -パッケージマネージャーは、install を実行するディレクトリにあるファイルから判定します。親ディレクトリはたどりません。各言語で、上の行から順に最初に見つかったものを使います。 +パッケージマネージャーは、install を実行するディレクトリにあるファイルから判定します。検出された pnpm workspace の Node メンバーは、ルートの install と lock の監査を共有します。それ以外は親ディレクトリをたどりません。各言語で、上の行から順に最初に見つかったものを使います。 | 言語 | 判定に使うファイル | 実行するコマンド | |------|--------------------|------------------| | Node.js | `pnpm-lock.yaml` | `pnpm install` | | | `yarn.lock` | `yarn install` | | | `bun.lock` / `bun.lockb` | `bun install` | -| | `package-lock.json`、または `package.json` のみ | `npm install` | +| | `npm-shrinkwrap.json` / `package-lock.json`、または `package.json` のみ | `npm install` | | Python | `uv.lock` | `uv sync` | | | `poetry.lock` | `poetry install` | | | `requirements.lock` / `requirements-dev.lock` | `rye sync` | @@ -371,17 +371,31 @@ age フィルターは、depup がマニフェストに書き込むバージョ | パッケージマネージャー | depup が渡すもの | 推移的依存への効果 | |------------------------|------------------|--------------------| -| pnpm | 環境変数 `npm_config_minimum_release_age=<分>` | pnpm v10.16 以降が適用する(それより古い pnpm は環境変数を無視する) | +| pnpm | 環境変数 `npm_config_minimum_release_age=<分>` と `pnpm_config_minimum_release_age=<分>` の両方 | 対応版の pnpm が解決時に適用し、depup が install 後の lock も監査する。両方の接頭辞で pnpm 10 と pnpm 11 以降に対応する | | uv | `--exclude-newer <日時>` | uv が依存の解決時に適用する | | Cargo | なし(`cargo update` のあとに depup が `Cargo.lock` を監査する) | 条件を満たさない crates.io のクレートを depup が差し戻す([下記](#rust-の-cargolock-監査)) | | mise | `--minimum-release-age <日時>` と `MISE_MINIMUM_RELEASE_AGE` | 公開日時が分かる前方一致の版を制限する。推移的依存への適用は `npm:`・`pypi:` の未固定依存だけ。完全固定・lock 済みのトップレベルの版には native の制限が効かない | -| npm、Yarn、Bun、pip、Poetry、Rye、Pipenv、Go、Bundler、Composer、Gradle、SwiftPM | なし | 適用されない(age フィルターが効くのは直接依存だけ) | +| npm | なし | package-lock 形式 2/3 の実際の解決版を install 後に検証する | +| Yarn、Bun | なし | lock の監査は未対応。age が有効なら終了コード 2 で通知する | +| pip、Poetry、Rye、Pipenv、Go、Bundler、Composer、Gradle、SwiftPM | なし | 適用されない(age フィルターが効くのは直接依存だけ) | -`--verbose` を付けると、今回使うパッケージマネージャーのうち、age フィルターが直接依存にしか効かないものを通知します。`--no-age` を指定し、プロジェクトと PM 自身のポリシーもない場合は、age の値をどこにも渡さず、Rust の監査も行いません。uv のパッケージ・index 単位の明示例外は uv 自身の仕様に従います。 +`--verbose` を付けると、今回使うパッケージマネージャーのうち、age フィルターが直接依存にしか効かないものを通知します。`--no-age` を指定し、プロジェクトと PM 自身のポリシーもない場合は、age の値をどこにも渡さず、Rust と Node の lock の監査も行いません。uv のパッケージ・index 単位の明示例外は uv 自身の仕様に従います。 + +#### Node の lock の監査 + +age が有効な `--install` では、直接依存・推移依存・開発依存の実際の解決版を検証します。更新がない再実行でも、ほかの install が失敗した場合でも既存 lock を検証します。lock がなく、install も不要な場合は監査しません。 + +- pnpm: `pnpm list --depth Infinity --lockfile-only --json` で lock の依存グラフを読みます。共有 workspace はルートと全メンバーを含め、最も厳しい設定を使います。必須依存の欠落や CLI の警告があれば未確認扱いにします。 +- npm: 優先される `npm-shrinkwrap.json`、または `package-lock.json` の形式 2/3 の `packages` テーブルを読み、各パッケージと workspace の必須依存の解決先も確認します。 +- Yarn、Bun、npm の形式 1: 現在は未対応として、age が有効なら終了コード 2 で通知します。 + +取得元が公開 npm レジストリだと確認できるパッケージだけを照会します。pnpm が生成した依存グラフの URL だけを根拠にせず、実際に有効なレジストリ設定も確認します。alias は実パッケージ名を使います。ローカル・workspace・Git 依存は公開日の監査対象外です。private や取得元が不明のレジストリ依存は、その名前を公開 npm へ送らず未確認として報告します。公開日不明、読み込み・照会の失敗、監査中の lock の変更、180 秒の時間切れも終了コード 2 になります。公開日は更新候補のフィルターと別に検証するため、deprecated や latest 以外のロックされた版も確認できます。 + +Node の監査では lock を書き換えません。違反は実際のロック版・公開日・基準日時を、未確認の依存は理由を常に表示します。PM 自身の age フィルターで新規の解決を制限できる場合も、最後の検証を省略しません。 #### Rust の `Cargo.lock` 監査 -Rust では、install の前後で `Cargo.lock` のバージョンが変わったクレートだけ、直接依存か推移的依存かを問わず公開日時を確認します。age フィルターの条件を満たさないものは差し戻します。 +Rust では、最終 `Cargo.lock` の crates.io の版を、無変更の直接依存・推移依存も含めて確認します。まず新しく解決されたクレートの age 違反を、元のマニフェストの制約と install 前の下限が許す範囲で差し戻します。その後、別の読み取り専用の検証で最終 lock の全件を確認します。 ```text ⠙ Auditing hyper [██████████████████████▓░░░░░░░] 18/24 (6s) @@ -389,7 +403,7 @@ Rust では、install の前後で `Cargo.lock` のバージョンが変わっ hyper 1.11.1 → 1.11.0 ``` -対象を「変わったもの」に限るのは、crates.io の利用ポリシーに従ってリクエストを 1 秒に 1 回までに抑えているためです。ロックファイル全体(多くは数百クレート)を調べると、それだけで数分かかります。監査には `Cargo.lock` ごとに 180 秒の上限があります。install 前に `Cargo.lock` がなかった場合はすべてのエントリが新規扱いになるため、この上限に達しやすくなります。crates.io 以外のレジストリのクレートは、crates.io で公開日時を調べられないので監査せず、ロックされた版のまま残します。 +crates.io API への照会は 1 秒に 1 回までに抑え、実行中の取得情報は再利用します。差し戻しと最終検証には、`Cargo.lock` ごとにそれぞれ独立した 180 秒の枠を設けます。最終検証では、[公式の sparse index](https://doc.rust-lang.org/cargo/reference/registry-index.html) の公開日を最大 8 件並列で取得します。日付がない場合や、若い版に公開者の例外を照合する場合は、レート制限付き API を使います。yank 済みのロック版も公開日を確認しますが、更新・差し戻し候補には使いません。未確認が残れば、無変更の再実行でも報告して終了コード 2 にします。既存 lock の読み込み失敗や、install が必要だったのに lock がない場合を成功扱いにしません。lock がない無変更のプロジェクトでは install も lock の監査も行いません。install 前の lock が読めない場合は、安全な下限を確定できないため自動差し戻しも行いません。crates.io 以外のレジストリのクレートは、crates.io で公開日時を調べられないので監査せず、ロックされた版のまま残します。 差し戻し先のバージョンは次のとおりです。 @@ -398,7 +412,7 @@ Rust では、install の前後で `Cargo.lock` のバージョンが変わっ どちらの場合も、install 前の `Cargo.lock` にあった同じ semver 系列の利用可能なバージョンより古くはしません。yank 済み・公開日不明などで crates.io の利用可能な版情報に含まれないバージョンは、この下限にも戻し先にも使いません。その場合は、互換性があり age フィルターを満たす古い版を選び、有効な候補がなければ未解消の違反として報告します。 -条件を満たすバージョンが利用可能な下限より古いものしかなければ、install 前のバージョンに戻します。install で入った変更だけを取り消すためです。戻した install 前のバージョンも age フィルターの条件を満たさない場合は、差し戻した一覧とは別の黄色い行で表示します。 +条件を満たすバージョンが利用可能な下限より古いものしかなければ、install 前のバージョンに戻します。install で入った変更だけを取り消すためです。戻した install 前のバージョンも age フィルターの条件を満たさない場合は、未解消の違反として終了コード 2 にし、差し戻した一覧とは別の黄色い行で表示します。下限にある無変更の違反も報告し、すでに lock にあったことを免除の理由にはしません。 ```text . — 1 crate(s) returned to the version locked before the install, which is also newer than --age: @@ -425,17 +439,20 @@ Rust では、install の前後で `Cargo.lock` のバージョンが変わっ 解決し直した結果 `Cargo.lock` から外れたクレートは `tokio 1.53.1 → removed` のように表示し、差し戻した件数に含めます。また、1 件ずつでもまとめてでも、差し戻すと別の新しいバージョンが `Cargo.lock` に入ることがあります。depup はそれも監査して差し戻し、これを決まった回数まで繰り返します。 -共有ワークスペースの lock には、対象メンバーの最も厳しい設定と、全メンバーに共通する公開者の例外だけを使います。lock の版がキャッシュ済みのレジストリ情報にない場合は、情報を 1 回取り直します。公開日不明、lock の読み込み・解析失敗、差し戻し失敗、監査未完了が残ると終了コードは `2` になります(install 失敗もある場合は `1` が優先)。install 前の版へ戻した場合は install による変更を取り消した扱いとし、それだけでは失敗にしません。 +共有ワークスペースの lock には、対象メンバーの最も厳しい設定と、全メンバーに共通する公開者の例外だけを使います。lock の版がキャッシュ済みのレジストリ情報にない場合は、情報を 1 回取り直します。公開日不明、lock の読み込み・解析失敗、差し戻し失敗、監査未完了が残ると終了コードは `2` になります(install 失敗もある場合は `1` が優先)。install 前の版へ戻しても、その版が age を満たさなければ未解消として扱います。 -差し戻したと数えるのは、`Cargo.lock` が実際に変わったときだけです。`cargo update` の終了コードを信じず、毎回ロックファイルを読み直して確かめます。最後の報告も、監査を終えた時点のロックファイルの状態から組み立てます。差し戻した結果は常に表示します。差し戻せなかったクレート、公開日時を取得できなかったクレート、時間の上限で確認できなかったクレートも、`--verbose` なしで件数を表示します。 +差し戻したと数えるのは、`Cargo.lock` が実際に変わったときだけです。`cargo update` の終了コードを信じず、毎回ロックファイルを読み直して確かめます。最後の報告も、監査を終えた時点のロックファイルの状態から組み立てます。差し戻した結果は常に表示します。差し戻せなかったクレート、公開日時を取得できなかったクレート、時間の上限で確認できなかったクレートも、`--verbose` なしで名前・試行時に要求した差し戻し先・理由を表示します。 ```text - . — 2 crate(s) could not be rolled back to satisfy --age (use --verbose for details) - . — 1 crate(s) could not be checked against --age: release date unavailable (use --verbose for details) + . — 2 crate(s) could not be rolled back to satisfy --age: + foo (2.1.3; requested: 2.1.2): cargo update failed: + bar (1.0.2): no older version satisfies --age + . — 1 crate(s) could not be checked against --age: + example (1.0.0): release date unavailable . — age audit stopped after 180s; 3 crate(s) left unchecked ``` -`--verbose` を付けると、監査の開始時に `Enforcing --age on crates changed in Cargo.lock...` と表示し、差し戻せなかったクレートと公開日時を取得できなかったクレートについて、1 件ずつ理由を示します。 +`--verbose` を付けると、監査の開始時に `Enforcing --age on registry crates in Cargo.lock...` と表示します。失敗の理由は通常出力でも表示します。 ```text . — 2 crate(s) could not be rolled back to satisfy --age: @@ -448,7 +465,7 @@ Rust では、install の前後で `Cargo.lock` のバージョンが変わっ | `Cargo.toml requires ...` | バージョン指定が、age フィルターの条件を満たすバージョンをすべて除外している | | `no older version satisfies --age` | ロックされた版より古いバージョンに、条件を満たすものがない | | `cargo update failed: ...` | cargo が差し戻しを受け付けなかった(続けて cargo のエラーを表示する) | -| `not attempted: ...` | 繰り返しの回数か時間の上限に達したため、差し戻しを試さなかった | +| `not attempted: ...` | install 前の下限より古くする必要がある、または繰り返しの回数か時間の上限に達したため、差し戻しを試さなかった | | `release date unavailable` | 公開日時を取得できず、条件を満たすか確認できなかった | 監査に未解決の問題が残る場合は終了コード `2` になります。 diff --git a/docs/usage.md b/docs/usage.md index 2b079cc..b3a43d9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -331,14 +331,14 @@ If the same dependency key is declared more than once in a manifest, or several With `--install`, depup runs each project's package manager after writing the manifests, so lock files and installed packages follow the new versions. - An install runs only for manifests that received at least one update, and never with `--dry-run`. -- Without [`.depup`](configuration.md#depup-configuration-file), every install runs in the target directory (the `PATH` argument, or the current directory), even when the updated manifest belongs to a workspace member. With `.depup`, each install runs in the deepest listed directory that contains the updated manifest, so nested apps install in their own directories. +- Node installs run at the shared pnpm workspace root or the manifest directory. Other languages use the target directory (PATH) without [`.depup`](configuration.md#depup-configuration-file), or the deepest listed directory containing the updated manifest when `.depup` is present. - Installs run one at a time, in directory path order, and each language runs at most once per directory. The package manager's output is captured instead of streamed; its stderr is printed only when the install fails. -If an install fails, depup still runs the remaining installs, prints the failed command with the package manager's stderr, and exits with code 1 at the end (`Error: Some package manager installs failed`). A package manager that is not installed counts as a failure. Manifests that were already rewritten are not rolled back, and the Rust age audit ([below](#auditing-cargolock-rust)) still runs even when another install fails. +If an install fails, depup still runs the remaining installs, prints the failed command with the package manager's stderr, and exits with code 1 at the end (`Error: Some package manager installs failed`). A package manager that is not installed counts as a failure. Manifests that were already rewritten are not rolled back, and the Rust and Node lockfile age audits still run even when another install fails. An unchanged `--install` run also audits existing locks; it does not rerun package managers when there are no manifest updates. ### Commands per Package Manager -The package manager is detected from files in the directory where the install runs; parent directories are not searched. Within each language, the first match wins: +The package manager is detected from files in the directory where the install runs. Node members of a detected pnpm workspace share its root install and lock audit; other package managers do not search parent directories. Within each language, the first match wins: | Language | Detected by | Command | |----------|-------------|---------| @@ -370,17 +370,31 @@ The age filter decides which versions depup writes into manifests. Whether it al | Package manager | What depup passes | Transitive dependencies | |-----------------|-------------------|-------------------------| -| pnpm | `npm_config_minimum_release_age=` (environment variable) | Filtered by pnpm v10.16 or later; older versions ignore the variable | +| pnpm | Both `npm_config_minimum_release_age=` and `pnpm_config_minimum_release_age=` | Native filtering on supported pnpm versions, plus a post-install lock audit. The two prefixes cover pnpm 10 and pnpm 11 or later | | uv | `--exclude-newer ` | Filtered when uv resolves them | | Cargo | Nothing; depup audits `Cargo.lock` after `cargo update` | crates.io crates that violate the age filter are rolled back ([below](#auditing-cargolock-rust)) | | mise | `--minimum-release-age ` and `MISE_MINIMUM_RELEASE_AGE` | Fuzzy top-level versions are filtered when timestamps are available. Only the `npm:` and `pypi:` backends pass the cutoff to unpinned transitive dependencies; exact pins and locked top-level versions bypass native filtering | -| npm, Yarn, Bun, pip, Poetry, Rye, Pipenv, Go, Bundler, Composer, Gradle, SwiftPM | Nothing | Not filtered; only direct dependencies follow the age filter | +| npm | Nothing | Resolved versions are verified after install for package-lock v2/v3 | +| Yarn, Bun | Nothing | Lock audit is currently unsupported; an active age policy produces exit 2 | +| pip, Poetry, Rye, Pipenv, Go, Bundler, Composer, Gradle, SwiftPM | Nothing | Not filtered; only direct dependencies follow the age filter | -With `--verbose`, depup prints a note naming the package managers used in the run for which the age filter covers direct dependencies only. With `--no-age` and no project or native policy, nothing age-related is passed and the Rust audit does not run. Native package/index exemptions configured in uv still follow uv's own semantics. +With `--verbose`, depup prints a note naming the package managers used in the run for which the age filter covers direct dependencies only. With `--no-age` and no project or native policy, nothing age-related is passed and neither the Rust nor Node lock audit runs. Native package/index exemptions configured in uv still follow uv's own semantics. + +#### Auditing Node Lockfiles + +With an active age policy, `--install` checks the actual resolved versions of direct and transitive dependencies, including dev dependencies. It also checks unchanged locks on subsequent runs and after another install fails. If no lock exists and no install is needed, no lock audit runs. + +- pnpm: reads the lock graph using `pnpm list --depth Infinity --lockfile-only --json`. Shared workspaces include all projects and the root, with the strictest member policy. Missing required dependencies or CLI warnings make the graph unverified. +- npm: reads the `packages` table of `npm-shrinkwrap.json` (preferred) or `package-lock.json`, format 2 or 3, and verifies required dependencies across packages and workspaces. +- Yarn, Bun, and npm format 1: currently reported as unsupported, with exit code 2 when age is active. + +Only packages whose registry source can be confirmed as public npm are queried. For pnpm, generated graph URLs alone are not source evidence; the effective registry configuration is checked too. Aliases use the real package name. Local, workspace, and Git dependencies are outside this release-date audit. Private or unknown registry sources are reported as unverified without querying public npm for their names. Missing publication dates, read or lookup failures, changed lock contents during the audit, and the 180-second time limit also produce exit code 2. Publication dates are checked independently of the update-candidate filters, so deprecated or non-latest locked versions can be verified. + +Node auditing does not rewrite the lockfile. Violations always show the actual locked version, publication date, and cutoff; unverified entries always show their reason. Native age filtering helps prevent new resolutions, but does not replace this final verification. #### Auditing `Cargo.lock` (Rust) -For Rust, depup checks the release dates of the crates whose version in `Cargo.lock` changed during the install, direct and transitive dependencies alike, and rolls back any that violate the age filter: +For Rust, depup checks all crates.io versions in the final `Cargo.lock`, including unchanged direct and transitive dependencies. It first rolls back newly resolved violations where the original manifest constraints and the pre-install minimum permit it, then performs a separate read-only verification of every final locked version: ```text ⠙ Auditing hyper [██████████████████████▓░░░░░░░] 18/24 (6s) @@ -388,7 +402,7 @@ For Rust, depup checks the release dates of the crates whose version in `Cargo.l hyper 1.11.1 → 1.11.0 ``` -Only changed entries are audited because depup limits crates.io requests to one per second, following its crawler policy; auditing an entire lock file (often hundreds of crates) would take several minutes on its own. The audit is capped at 180 seconds per `Cargo.lock`. If `Cargo.lock` did not exist before the install, every entry counts as changed, so the cap is more likely to be reached. Crates from registries other than crates.io are not audited and keep their locked versions, because their release dates cannot be looked up on crates.io. +depup limits crates.io API requests to one per second and reuses metadata fetched during the run. Rollback and final verification each have a separate 180-second budget per `Cargo.lock`. Final verification reads immutable publication dates from the [official sparse index](https://doc.rust-lang.org/cargo/reference/registry-index.html), with at most eight concurrent requests; missing dates or young versions needing publisher verification use the rate-limited API. This metadata includes yanked locked versions without allowing them as update or rollback candidates. Unchecked entries are reported and produce exit code 2, including on a no-change retry. An unreadable existing lock, or a missing lock after an install was needed, cannot pass. A lockless project with no updates does not run an install or lock audit. An unreadable pre-install lock also disables automatic rollback because its minimum cannot be established safely. Crates from registries other than crates.io are not audited and keep their locked versions, because their release dates cannot be looked up on crates.io. A crate is rolled back to the following version: @@ -397,7 +411,7 @@ A crate is rolled back to the following version: In both cases, a rollback never goes below an available pre-install version from the same semver series. Pre-install versions absent from usable crates.io metadata, including yanked versions and versions with unreadable release dates, are excluded from both this minimum and restoration. In that case, depup chooses an older compatible version that satisfies the age filter, or reports an unresolved violation if none exists. -If only versions below an available minimum satisfy the age filter, the crate goes back to that pre-install version, so depup undoes only the change the install made. When that pre-install version is itself newer than the age filter allows, it is reported on a separate yellow line instead of as rolled back: +If only versions below an available minimum satisfy the age filter, the crate goes back to that pre-install version, so depup undoes only the change the install made. When that pre-install version is itself newer than the age filter allows, it remains an unresolved violation (exit code 2) and is reported on a separate yellow line instead of as rolled back. An unchanged violation at this minimum is also reported; it is never exempted merely because it was already locked: ```text . — 1 crate(s) returned to the version locked before the install, which is also newer than --age: @@ -424,17 +438,20 @@ Crates rolled back together are reported with the others: A crate that drops out of `Cargo.lock` during the joint resolution is listed as `tokio 1.53.1 → removed` and counted as rolled back. Any rollback, one at a time or joint, can also bring other new versions into `Cargo.lock`; depup audits those as well and rolls them back in turn, up to a fixed number of rounds. -A shared workspace lock uses the strictest member policy and only publisher exemptions common to every member. If a locked version is absent from cached registry metadata, depup refreshes that metadata once. Missing metadata, unreadable or invalid locks, failed rollbacks, and an unfinished audit cause exit code `2` (an install failure still takes priority as `1`). Restoring the pre-install version counts as undoing the install and does not by itself fail the run. +A shared workspace lock uses the strictest member policy and only publisher exemptions common to every member. If a locked version is absent from cached registry metadata, depup refreshes that metadata once. Missing metadata, unreadable or invalid locks, failed rollbacks, and an unfinished audit cause exit code `2` (an install failure still takes priority as `1`). Restoring the pre-install version is still unresolved if it does not satisfy the age policy. -A rollback counts only when `Cargo.lock` actually changed; depup rereads the lock file after each `cargo update` instead of trusting its exit status, and builds the final report from the lock file as it stands when the audit ends. Rollbacks are always reported. Crates that could not be rolled back, crates whose release date is unavailable, and crates left unchecked when the time cap is reached are counted even without `--verbose`: +A rollback counts only when `Cargo.lock` actually changed; depup rereads the lock file after each `cargo update` instead of trusting its exit status, and builds the final report from the lock file as it stands when the audit ends. Rollbacks are always reported. Crates that could not be rolled back, crates whose release date is unavailable, and crates left unchecked when the time cap is reached always show names, requested rollback targets when attempted, and reasons, even without `--verbose`: ```text - . — 2 crate(s) could not be rolled back to satisfy --age (use --verbose for details) - . — 1 crate(s) could not be checked against --age: release date unavailable (use --verbose for details) + . — 2 crate(s) could not be rolled back to satisfy --age: + foo (2.1.3; requested: 2.1.2): cargo update failed: + bar (1.0.2): no older version satisfies --age + . — 1 crate(s) could not be checked against --age: + example (1.0.0): release date unavailable . — age audit stopped after 180s; 3 crate(s) left unchecked ``` -With `--verbose`, depup prints `Enforcing --age on crates changed in Cargo.lock...` when the audit starts, and gives the reason for each crate that could not be rolled back or whose release date is unavailable: +With `--verbose`, depup prints `Enforcing --age on registry crates in Cargo.lock...` when the audit starts. Failure reasons are also shown in normal output: ```text . — 2 crate(s) could not be rolled back to satisfy --age: