aube_resolver/types.rs
1use aube_lockfile::LocalSource;
2use std::collections::BTreeMap;
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8/// Hook invoked once per resolved package, right after its version has
9/// been picked from the packument and before its dependency set is
10/// enqueued. Implementations may mutate `dependencies`,
11/// `optionalDependencies`, `peerDependencies`, and
12/// `peerDependenciesMeta`; every other field is ignored on the way
13/// back, matching how pnpm's `readPackage` hook is used in the wild.
14///
15/// The trait is deliberately shaped to let a single long-lived node
16/// subprocess implement it — `&mut self` so the impl can own stdin /
17/// stdout halves of the child without interior mutability, and a boxed
18/// future because `async fn` in dyn-compatible traits still requires
19/// third-party crates we haven't pulled in.
20pub trait ReadPackageHook: Send {
21 fn read_package<'a>(
22 &'a mut self,
23 pkg: aube_registry::VersionMetadata,
24 ) -> Pin<Box<dyn Future<Output = Result<aube_registry::VersionMetadata, String>> + Send + 'a>>;
25}
26
27/// Supply-chain mitigation: forbid versions younger than `min_age`
28/// unless the package (or specific version) is exempted by `exclude`.
29/// Mirrors pnpm's `minimumReleaseAge` / `minimumReleaseAgeExclude` /
30/// `minimumReleaseAgeStrict` triplet. Constructed by the install
31/// command, threaded into [`Resolver::with_minimum_release_age`].
32#[derive(Debug, Clone)]
33pub struct MinimumReleaseAge {
34 /// Minutes a version must have aged in the registry. `0` disables.
35 pub minutes: u64,
36 /// Packages exempt from the cutoff. Supports the same syntax pnpm's
37 /// `minimumReleaseAgeExclude` does: bare names, `*` name globs (e.g.
38 /// `@myorg/*`), and exact-version unions (`pkg@1.2.3 || 1.2.4`).
39 pub exclude: crate::trust::PackageVersionPolicy,
40 /// When true, fail the install if no version satisfies the range
41 /// without violating the cutoff. When false (the pnpm default), the
42 /// resolver falls back to the lowest satisfying version, ignoring
43 /// the cutoff for that pick only.
44 pub strict: bool,
45}
46
47impl Default for MinimumReleaseAge {
48 fn default() -> Self {
49 // `PackageVersionPolicy::default()` carries the *trust* exclude
50 // list; the age gate must start with no exemptions, so use the
51 // explicitly-empty constructor.
52 Self {
53 minutes: 0,
54 exclude: crate::trust::PackageVersionPolicy::empty(),
55 strict: false,
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
61pub struct DependencyPolicy {
62 pub package_extensions: Vec<PackageExtension>,
63 pub allowed_deprecated_versions: BTreeMap<String, String>,
64 pub trust_policy: TrustPolicy,
65 pub trust_policy_exclude: crate::trust::TrustExcludeRules,
66 pub trust_policy_ignore_after: Option<u64>,
67 pub block_exotic_subdeps: bool,
68}
69
70impl Default for DependencyPolicy {
71 fn default() -> Self {
72 Self {
73 package_extensions: Vec::new(),
74 allowed_deprecated_versions: BTreeMap::new(),
75 trust_policy: TrustPolicy::default(),
76 trust_policy_exclude: crate::trust::TrustExcludeRules::default(),
77 trust_policy_ignore_after: None,
78 block_exotic_subdeps: true,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct PackageExtension {
85 pub selector: String,
86 pub dependencies: BTreeMap<String, String>,
87 pub optional_dependencies: BTreeMap<String, String>,
88 pub peer_dependencies: BTreeMap<String, String>,
89 pub peer_dependencies_meta: BTreeMap<String, aube_registry::PeerDepMeta>,
90}
91
92/// Default is `NoDowngrade` to match the user-facing default in
93/// `crates/aube-settings/settings.toml`. The install command overrides
94/// this from the resolved settings anyway, but library consumers
95/// constructing a `Resolver` via [`Resolver::new`] inherit the
96/// documented default behavior without extra plumbing.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
98pub enum TrustPolicy {
99 #[default]
100 NoDowngrade,
101 Off,
102}
103
104impl MinimumReleaseAge {
105 /// Compute the absolute ISO-8601 UTC cutoff string. Returns `None`
106 /// when the feature is disabled (`minutes == 0`). Format matches
107 /// the npm registry's `time` map so a lexicographic compare on the
108 /// raw strings doubles as an instant compare.
109 pub fn cutoff(&self) -> Option<String> {
110 if self.minutes == 0 {
111 return None;
112 }
113 let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
114 let cutoff_secs = now.saturating_sub(self.minutes * 60);
115 Some(format_iso8601_utc(cutoff_secs))
116 }
117}
118
119/// Format a Unix epoch second count as an ISO-8601 UTC `Z` string. The
120/// resolver only ever compares these against npm registry timestamps,
121/// which are emitted in this exact shape — so we can ship our own
122/// formatter and skip pulling in `chrono`/`time`. Algorithm adapted
123/// from the days-from-epoch trick used by `time` and `civil` crates.
124///
125/// `aube/src/commands/sbom.rs` carries a near-identical formatter
126/// for the SPDX/CycloneDX writers; that one emits seconds-only
127/// (`...:00Z`) since SBOM consumers don't expect millis. Don't merge
128/// without checking which format each caller needs — the npm registry
129/// `time` map always uses `.000Z`, lex compare relies on it.
130pub(crate) fn format_iso8601_utc(epoch_secs: u64) -> String {
131 let days = (epoch_secs / 86_400) as i64;
132 let secs_of_day = epoch_secs % 86_400;
133 let h = secs_of_day / 3600;
134 let m = (secs_of_day % 3600) / 60;
135 let s = secs_of_day % 60;
136 let (y, mo, d) = civil_from_days(days);
137 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.000Z")
138}
139
140/// Convert a day count from the Unix epoch (1970-01-01) to a
141/// proleptic Gregorian (year, month, day). Lifted from Howard Hinnant's
142/// `civil_from_days` paper, which the `time` crate uses.
143fn civil_from_days(days: i64) -> (i64, u32, u32) {
144 let z = days + 719_468;
145 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
146 let doe = (z - era * 146_097) as u64;
147 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
148 let y = yoe as i64 + era * 400;
149 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
150 let mp = (5 * doy + 2) / 153;
151 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
152 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
153 let y = if m <= 2 { y + 1 } else { y };
154 (y, m, d)
155}
156
157/// A resolved package emitted during resolution, allowing the caller
158/// to start fetching tarballs before resolution is fully complete.
159#[derive(Debug, Clone)]
160pub struct ResolvedPackage {
161 pub dep_path: String,
162 pub name: String,
163 pub version: String,
164 pub integrity: Option<String>,
165 /// Exact tarball URL reported by the packument's `dist.tarball`
166 /// field, or preserved from an existing lockfile. Most npm
167 /// packages can re-derive this from name + version, but JSR's
168 /// npm-compatible registry uses opaque tarball paths, so fetchers
169 /// must prefer this when it is available.
170 pub tarball_url: Option<String>,
171 /// Real registry name when this package is an npm-alias
172 /// (`"h3-v2": "npm:h3@..."`). `name` is the alias (`h3-v2` — the
173 /// folder in `node_modules/`), `alias_of` is what the streaming
174 /// fetch client uses to derive the tarball URL and store-index
175 /// key. `None` for non-aliased packages, in which case `name`
176 /// already matches the registry.
177 pub alias_of: Option<String>,
178 /// Set for non-registry packages (`file:` / `link:`). Downstream
179 /// fetchers short-circuit the tarball path and materialize from
180 /// disk instead.
181 pub local_source: Option<LocalSource>,
182 /// npm `os`/`cpu`/`libc` arrays straight from the packument (or
183 /// lockfile). The streaming fetch coordinator uses them to defer
184 /// tarball downloads for optional natives that won't install on
185 /// the host — a post-resolve catch-up pass after `filter_graph`
186 /// fetches anything that survived the graph trim but got deferred,
187 /// so required-platform-mismatched packages (which `filter_graph`
188 /// doesn't drop) still get their tarball before link.
189 pub os: aube_lockfile::PlatformList,
190 pub cpu: aube_lockfile::PlatformList,
191 pub libc: aube_lockfile::PlatformList,
192 /// Deprecation message from the registry, carried forward so the
193 /// install command can render user-facing warnings without a
194 /// second packument fetch. Only populated on the fresh-resolve
195 /// path; lockfile-reuse and `file:`/`link:` packages carry `None`
196 /// because the packument wasn't consulted. `allowedDeprecatedVersions`
197 /// suppression is applied upstream, so anything set here is meant
198 /// to surface to the user.
199 pub deprecated: Option<Arc<str>>,
200 /// Best-effort install-size hint from the packument's
201 /// `dist.unpackedSize`. Summed across the resolve stream to drive
202 /// the `4.2 MB / ~13.8 MB` segment in the progress bar. `None`
203 /// when the packument doesn't carry the field (older publishes,
204 /// `file:`/`link:` deps, JSR packages without npm metadata).
205 pub unpacked_size: Option<u64>,
206 /// Resolver's view of remaining work at the moment this package
207 /// was sent: the size of the BFS queue plus any packument fetches
208 /// still in flight plus transitives deferred behind a time-based
209 /// cutoff. `received_count + pending` is a non-strict lower bound
210 /// on the final resolved-package count, used by the install
211 /// progress UI to render a real bar during the resolving phase
212 /// instead of an empty placeholder. Approximate by construction —
213 /// a packument fetch completing without enqueueing children can
214 /// transiently shrink the frontier, so the install side raises
215 /// the displayed denominator with `fetch_max` semantics.
216 pub pending: usize,
217}
218
219impl ResolvedPackage {
220 /// Registry lookup name — `alias_of` when set, otherwise `name`.
221 /// Every tarball URL + store index site routes through this
222 /// accessor so aliased packages resolve to the real registry
223 /// entry without leaking the alias-qualified name into network
224 /// requests (where it would 404).
225 pub fn registry_name(&self) -> &str {
226 self.alias_of.as_deref().unwrap_or(&self.name)
227 }
228}
229
230/// Which version-picking strategy the resolver uses for a workspace.
231/// Mirrors pnpm's `resolution-mode` setting.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
233pub enum ResolutionMode {
234 /// Classic pnpm behavior: every dep resolves to the highest version
235 /// satisfying its range.
236 #[default]
237 Highest,
238 /// Pick the lowest version that satisfies each direct-dep range,
239 /// then constrain transitive picks to versions published on or
240 /// before a cutoff date derived from the max publish time of
241 /// already-locked packages. Matches pnpm's `time-based` mode.
242 TimeBased,
243}