Skip to main content

aube_resolver/
types.rs

1use aube_lockfile::LocalSource;
2use std::collections::{BTreeMap, HashSet};
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` for
28/// every package whose name isn't in `exclude`. Mirrors pnpm's
29/// `minimumReleaseAge` / `minimumReleaseAgeExclude` /
30/// `minimumReleaseAgeStrict` triplet. Constructed by the install
31/// command, threaded into [`Resolver::with_minimum_release_age`].
32#[derive(Debug, Clone, Default)]
33pub struct MinimumReleaseAge {
34    /// Minutes a version must have aged in the registry. `0` disables.
35    pub minutes: u64,
36    /// Package names skipped by the cutoff filter entirely.
37    pub exclude: HashSet<String>,
38    /// When true, fail the install if no version satisfies the range
39    /// without violating the cutoff. When false (the pnpm default), the
40    /// resolver falls back to the lowest satisfying version, ignoring
41    /// the cutoff for that pick only.
42    pub strict: bool,
43}
44
45#[derive(Debug, Clone)]
46pub struct DependencyPolicy {
47    pub package_extensions: Vec<PackageExtension>,
48    pub allowed_deprecated_versions: BTreeMap<String, String>,
49    pub trust_policy: TrustPolicy,
50    pub trust_policy_exclude: crate::trust::TrustExcludeRules,
51    pub trust_policy_ignore_after: Option<u64>,
52    pub block_exotic_subdeps: bool,
53}
54
55impl Default for DependencyPolicy {
56    fn default() -> Self {
57        Self {
58            package_extensions: Vec::new(),
59            allowed_deprecated_versions: BTreeMap::new(),
60            trust_policy: TrustPolicy::default(),
61            trust_policy_exclude: crate::trust::TrustExcludeRules::default(),
62            trust_policy_ignore_after: None,
63            block_exotic_subdeps: true,
64        }
65    }
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct PackageExtension {
70    pub selector: String,
71    pub dependencies: BTreeMap<String, String>,
72    pub optional_dependencies: BTreeMap<String, String>,
73    pub peer_dependencies: BTreeMap<String, String>,
74    pub peer_dependencies_meta: BTreeMap<String, aube_registry::PeerDepMeta>,
75}
76
77/// Default is `NoDowngrade` to match the user-facing default in
78/// `crates/aube-settings/settings.toml`. The install command overrides
79/// this from the resolved settings anyway, but library consumers
80/// constructing a `Resolver` via [`Resolver::new`] inherit the
81/// documented default behavior without extra plumbing.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
83pub enum TrustPolicy {
84    #[default]
85    NoDowngrade,
86    Off,
87}
88
89impl MinimumReleaseAge {
90    /// Compute the absolute ISO-8601 UTC cutoff string. Returns `None`
91    /// when the feature is disabled (`minutes == 0`). Format matches
92    /// the npm registry's `time` map so a lexicographic compare on the
93    /// raw strings doubles as an instant compare.
94    pub fn cutoff(&self) -> Option<String> {
95        if self.minutes == 0 {
96            return None;
97        }
98        let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
99        let cutoff_secs = now.saturating_sub(self.minutes * 60);
100        Some(format_iso8601_utc(cutoff_secs))
101    }
102}
103
104/// Format a Unix epoch second count as an ISO-8601 UTC `Z` string. The
105/// resolver only ever compares these against npm registry timestamps,
106/// which are emitted in this exact shape — so we can ship our own
107/// formatter and skip pulling in `chrono`/`time`. Algorithm adapted
108/// from the days-from-epoch trick used by `time` and `civil` crates.
109///
110/// `aube/src/commands/sbom.rs` carries a near-identical formatter
111/// for the SPDX/CycloneDX writers; that one emits seconds-only
112/// (`...:00Z`) since SBOM consumers don't expect millis. Don't merge
113/// without checking which format each caller needs — the npm registry
114/// `time` map always uses `.000Z`, lex compare relies on it.
115pub(crate) fn format_iso8601_utc(epoch_secs: u64) -> String {
116    let days = (epoch_secs / 86_400) as i64;
117    let secs_of_day = epoch_secs % 86_400;
118    let h = secs_of_day / 3600;
119    let m = (secs_of_day % 3600) / 60;
120    let s = secs_of_day % 60;
121    let (y, mo, d) = civil_from_days(days);
122    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}.000Z")
123}
124
125/// Convert a day count from the Unix epoch (1970-01-01) to a
126/// proleptic Gregorian (year, month, day). Lifted from Howard Hinnant's
127/// `civil_from_days` paper, which the `time` crate uses.
128fn civil_from_days(days: i64) -> (i64, u32, u32) {
129    let z = days + 719_468;
130    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
131    let doe = (z - era * 146_097) as u64;
132    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
133    let y = yoe as i64 + era * 400;
134    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
135    let mp = (5 * doy + 2) / 153;
136    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
137    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
138    let y = if m <= 2 { y + 1 } else { y };
139    (y, m, d)
140}
141
142/// A resolved package emitted during resolution, allowing the caller
143/// to start fetching tarballs before resolution is fully complete.
144#[derive(Debug, Clone)]
145pub struct ResolvedPackage {
146    pub dep_path: String,
147    pub name: String,
148    pub version: String,
149    pub integrity: Option<String>,
150    /// Exact tarball URL reported by the packument's `dist.tarball`
151    /// field, or preserved from an existing lockfile. Most npm
152    /// packages can re-derive this from name + version, but JSR's
153    /// npm-compatible registry uses opaque tarball paths, so fetchers
154    /// must prefer this when it is available.
155    pub tarball_url: Option<String>,
156    /// Real registry name when this package is an npm-alias
157    /// (`"h3-v2": "npm:h3@..."`). `name` is the alias (`h3-v2` — the
158    /// folder in `node_modules/`), `alias_of` is what the streaming
159    /// fetch client uses to derive the tarball URL and store-index
160    /// key. `None` for non-aliased packages, in which case `name`
161    /// already matches the registry.
162    pub alias_of: Option<String>,
163    /// Set for non-registry packages (`file:` / `link:`). Downstream
164    /// fetchers short-circuit the tarball path and materialize from
165    /// disk instead.
166    pub local_source: Option<LocalSource>,
167    /// npm `os`/`cpu`/`libc` arrays straight from the packument (or
168    /// lockfile). The streaming fetch coordinator uses them to defer
169    /// tarball downloads for optional natives that won't install on
170    /// the host — a post-resolve catch-up pass after `filter_graph`
171    /// fetches anything that survived the graph trim but got deferred,
172    /// so required-platform-mismatched packages (which `filter_graph`
173    /// doesn't drop) still get their tarball before link.
174    pub os: aube_lockfile::PlatformList,
175    pub cpu: aube_lockfile::PlatformList,
176    pub libc: aube_lockfile::PlatformList,
177    /// Deprecation message from the registry, carried forward so the
178    /// install command can render user-facing warnings without a
179    /// second packument fetch. Only populated on the fresh-resolve
180    /// path; lockfile-reuse and `file:`/`link:` packages carry `None`
181    /// because the packument wasn't consulted. `allowedDeprecatedVersions`
182    /// suppression is applied upstream, so anything set here is meant
183    /// to surface to the user.
184    pub deprecated: Option<Arc<str>>,
185}
186
187impl ResolvedPackage {
188    /// Registry lookup name — `alias_of` when set, otherwise `name`.
189    /// Every tarball URL + store index site routes through this
190    /// accessor so aliased packages resolve to the real registry
191    /// entry without leaking the alias-qualified name into network
192    /// requests (where it would 404).
193    pub fn registry_name(&self) -> &str {
194        self.alias_of.as_deref().unwrap_or(&self.name)
195    }
196}
197
198/// Which version-picking strategy the resolver uses for a workspace.
199/// Mirrors pnpm's `resolution-mode` setting.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum ResolutionMode {
202    /// Classic pnpm behavior: every dep resolves to the highest version
203    /// satisfying its range.
204    #[default]
205    Highest,
206    /// Pick the lowest version that satisfies each direct-dep range,
207    /// then constrain transitive picks to versions published on or
208    /// before a cutoff date derived from the max publish time of
209    /// already-locked packages. Matches pnpm's `time-based` mode.
210    TimeBased,
211}