aube-store 1.39.0

Content-addressable global package store for Aube
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
use crate::{Error, PackageIndex};
use sha1::Sha1;
use sha2::{Digest as _, Sha256, Sha384, Sha512};
use std::cell::RefCell;

thread_local! {
    static SHA512_HASHER: RefCell<Sha512> = RefCell::new(Sha512::new());
}

pub const SHA512_INTEGRITY_PREFIX: &str = "sha512-";

/// Subresource Integrity (SRI) algorithm prefixes aube accepts in
/// `dist.integrity`. sha512 is what modern registries emit; sha1 is
/// kept for legacy packages (e.g. `co@4.6.0`) that were published
/// before npm's 2017 SRI rollout and never had their metadata rewritten.
const SRI_PREFIXES: &[(&str, IntegrityAlgo)] = &[
    ("sha512-", IntegrityAlgo::Sha512),
    ("sha384-", IntegrityAlgo::Sha384),
    ("sha256-", IntegrityAlgo::Sha256),
    ("sha1-", IntegrityAlgo::Sha1),
];

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum IntegrityAlgo {
    Sha1,
    Sha256,
    Sha384,
    Sha512,
}

impl IntegrityAlgo {
    fn prefix(self) -> &'static str {
        match self {
            Self::Sha1 => "sha1-",
            Self::Sha256 => "sha256-",
            Self::Sha384 => "sha384-",
            Self::Sha512 => "sha512-",
        }
    }
}

fn parse_sri(expected: &str) -> Option<(IntegrityAlgo, &str)> {
    SRI_PREFIXES
        .iter()
        .find_map(|(prefix, algo)| expected.strip_prefix(prefix).map(|rest| (*algo, rest)))
}

/// Validate a package name and return the `safe_name` form used as a
/// cache filename stem (`/` collapsed to `__` so scoped names survive
/// a single path component). Refuses anything outside the npm name
/// grammar so a hostile packument cannot turn a cache write into an
/// arbitrary-file-write primitive. Public so callers in
/// `aube-registry` and `aube` (which own separate cache layouts under
/// the same cache root) can share one validator.
///
/// A malicious packument can set `name` to `../../etc/passwd` (or, on
/// Windows, to something with a drive prefix or backslash). The old
/// `name.replace('/', "__")` only stripped forward slashes, so
/// `index_dir().join(format!("{name}@{version}.json"))` would silently
/// resolve outside the cache directory on the first resolve of the
/// hostile package.
///
/// Accepted grammar is `[A-Za-z0-9_.-]` per component, with a single
/// optional `@scope/` prefix. Uppercase and leading `.` / `_` are
/// allowed on purpose: npm's registry bans them for *new* publishes
/// but thousands of pre-rule packages (`JSONStream`, `Base64`, etc.)
/// still resolve fine under pnpm and bun, and mirroring the registry's
/// publish grammar here would block their cache path and break
/// install. The only rejects are empty components, `.` / `..`, the
/// 214-char length ceiling, and any byte outside the grammar.
pub fn validate_and_encode_name(name: &str) -> Option<String> {
    if name.is_empty() || name.len() > 214 {
        return None;
    }
    let (scope, bare) = match name.strip_prefix('@') {
        Some(rest) => {
            let (s, b) = rest.split_once('/')?;
            (Some(s), b)
        }
        None => (None, name),
    };
    let ok_component = |s: &str| -> bool {
        // npm's registry bars new packages from leading `.` / `_` but
        // historical packages that predate the rule still resolve
        // fine, and scoped private registries allow them. Only bar
        // empty and `.`/`..` since those collide with path components
        // after the `/` → `__` folding.
        if s.is_empty() || s == "." || s == ".." {
            return false;
        }
        s.bytes()
            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
    };
    if let Some(s) = scope
        && !ok_component(s)
    {
        return None;
    }
    if !ok_component(bare) {
        return None;
    }
    Some(name.replace('/', "__"))
}

/// Check a version string for use as a cache filename component.
/// The lockfile already constrains versions to semver-ish shapes, but
/// the cache path is independent of the lockfile on the write side so
/// a crafted packument version would still land here. Returns `true`
/// for anything the cache path builder is willing to accept.
pub fn validate_version(version: &str) -> bool {
    if version.is_empty() || version.len() > 256 {
        return false;
    }
    // pnpm and bun sometimes route non-semver specs (git URLs, file
    // specs, aliased registries) through the `version` slot, so the
    // guard only needs to block what actually breaks the cache path
    // builder: path separators on any platform, `\0`, control chars,
    // and the two "this is a directory name" aliases.
    if version
        .bytes()
        .any(|b| b.is_ascii_control() || matches!(b, b'/' | b'\\' | b'\0'))
    {
        return false;
    }
    if version == "." || version == ".." {
        return false;
    }
    true
}

/// Verify that data matches an SRI integrity hash. Accepts any of
/// `sha512-` / `sha384-` / `sha256-` / `sha1-` prefixed base64 digests
/// — the set npm and pnpm accept in `dist.integrity`. Returns `Ok(())`
/// on match, `Err(Error::Integrity)` on mismatch or unknown algorithm.
pub fn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> {
    let Some((algo, expected_b64)) = parse_sri(expected) else {
        return Err(Error::Integrity(format!(
            "unsupported integrity format (expected sha1/sha256/sha384/sha512-...): {expected}"
        )));
    };

    // Stack-buffer the actual digest (max sha512 = 64 bytes) so the
    // hot path stays allocation-free. sha512 reuses the thread-local
    // hasher because it's the common case by 3+ orders of magnitude;
    // the legacy algorithms one-shot a fresh hasher.
    let mut actual_buf = [0u8; 64];
    let actual_len = match algo {
        IntegrityAlgo::Sha1 => {
            let d = Sha1::digest(data);
            actual_buf[..d.len()].copy_from_slice(&d);
            d.len()
        }
        IntegrityAlgo::Sha256 => {
            let d = Sha256::digest(data);
            actual_buf[..d.len()].copy_from_slice(&d);
            d.len()
        }
        IntegrityAlgo::Sha384 => {
            let d = Sha384::digest(data);
            actual_buf[..d.len()].copy_from_slice(&d);
            d.len()
        }
        IntegrityAlgo::Sha512 => SHA512_HASHER.with(|cell| {
            let mut hasher = cell.borrow_mut();
            hasher.reset();
            hasher.update(data);
            let d = hasher.finalize_reset();
            actual_buf[..d.len()].copy_from_slice(&d);
            d.len()
        }),
    };
    let actual = &actual_buf[..actual_len];

    use base64::Engine;
    let engine = base64::engine::general_purpose::STANDARD;
    let mut expected_digest = [0u8; 64];
    let matched = engine
        .decode_slice(expected_b64, &mut expected_digest)
        .map(|n| n == actual_len && expected_digest[..n] == actual[..])
        .unwrap_or(false);
    if matched {
        Ok(())
    } else {
        let actual_b64 = engine.encode(actual);
        Err(Error::Integrity(format!(
            "integrity mismatch: expected {expected}, got {prefix}{actual_b64}",
            prefix = algo.prefix(),
        )))
    }
}

/// Return the npm Subresource Integrity string for `data` using SHA-512.
pub fn sha512_integrity(data: &[u8]) -> String {
    let digest = SHA512_HASHER.with(|cell| {
        let mut hasher = cell.borrow_mut();
        hasher.reset();
        hasher.update(data);
        hasher.finalize_reset()
    });
    let mut digest_buf = [0u8; 64];
    digest_buf.copy_from_slice(&digest);
    sha512_integrity_from_digest(&digest_buf)
}

/// Return the npm Subresource Integrity string for a precomputed
/// SHA-512 digest.
pub fn sha512_integrity_from_digest(digest: &[u8; 64]) -> String {
    use base64::Engine;
    format!(
        "{SHA512_INTEGRITY_PREFIX}{}",
        base64::engine::general_purpose::STANDARD.encode(digest)
    )
}

/// Verify a precomputed SHA-512 digest against an SRI integrity
/// string. Used by the streaming-tarball fetch path: SHA-512 is
/// computed during the chunk read loop, then handed here so the
/// owned `Bytes` are not re-hashed on the import side. Saves one
/// pass over the buffer (~7 ms / 5 MB tarball).
///
/// Returns `Ok(true)` when the SRI uses SHA-512 and the digest
/// matches. Returns `Ok(false)` when the SRI uses a non-SHA-512
/// algo (legacy SHA-1 / SHA-256 / SHA-384) so the caller can
/// fall through to the buffered `verify_integrity` path that
/// re-hashes with the right algo. Returns `Err` on parse failure
/// or SHA-512 mismatch.
pub fn verify_precomputed_sha512(actual: &[u8; 64], expected: &str) -> Result<bool, Error> {
    let Some((algo, expected_b64)) = parse_sri(expected) else {
        return Err(Error::Integrity(format!(
            "unsupported integrity format (expected sha1/sha256/sha384/sha512-...): {expected}"
        )));
    };
    if !matches!(algo, IntegrityAlgo::Sha512) {
        return Ok(false);
    }
    use base64::Engine;
    let engine = base64::engine::general_purpose::STANDARD;
    let mut expected_digest = [0u8; 64];
    let decoded_len = match engine.decode_slice(expected_b64, &mut expected_digest) {
        Ok(n) => n,
        Err(e) => {
            return Err(Error::Integrity(format!(
                "integrity field has malformed base64: {expected} ({e})"
            )));
        }
    };
    if decoded_len != 64 {
        return Err(Error::Integrity(format!(
            "integrity field decoded to {decoded_len} bytes, expected 64 for sha512: {expected}"
        )));
    }
    if expected_digest[..decoded_len] == actual[..] {
        Ok(true)
    } else {
        let actual_b64 = engine.encode(actual);
        Err(Error::Integrity(format!(
            "integrity mismatch: expected {expected}, got sha512-{actual_b64}",
        )))
    }
}

/// Cross-check that an extracted tarball's `package.json` reports the
/// same `name` and `version` the registry told us to fetch. This is the
/// implementation behind the `strictStorePkgContentCheck` setting and
/// guards against registry-substitution attacks where a tarball is
/// served under one (name, version) but actually contains a different
/// package on disk.
///
/// `index` must be the result of a freshly-completed `import_tarball`
/// (or `import_directory`) — the helper reads `package.json` straight
/// from the on-disk store path recorded in the index, so the bytes
/// being validated are exactly the bytes that just landed in the CAS.
///
/// Returns `Ok(())` when both fields match, `Err(Error::PkgContentMismatch)`
/// when they don't, and `Err(Error::Tar)` if the manifest is missing
/// or unparseable. We deliberately treat a missing/broken manifest as
/// a check failure rather than silently passing — a registry tarball
/// without a usable `package.json` is itself a corruption signal.
pub fn validate_pkg_content(
    index: &PackageIndex,
    expected_name: &str,
    expected_version: &str,
) -> Result<(), Error> {
    // The two error paths below intentionally omit the
    // `{expected_name}@{expected_version}` coordinate. Every caller
    // wraps with `miette!("{name}@{version}: {e}")` (mirroring the
    // Error::Integrity path), so embedding it here would print the
    // same coordinate twice — same rationale as the
    // Error::PkgContentMismatch return below.
    let stored = index
        .get("package.json")
        .ok_or_else(|| Error::Tar("package.json missing from tarball".to_string()))?;
    let bytes =
        std::fs::read(&stored.store_path).map_err(|e| Error::Io(stored.store_path.clone(), e))?;
    let v: serde_json::Value = sonic_rs::from_slice(&bytes)
        .or_else(|_| serde_json::from_slice(&bytes))
        .map_err(|e| Error::Tar(format!("invalid package.json: {e}")))?;
    let actual_name = v.get("name").and_then(|n| n.as_str()).unwrap_or("");
    let actual_version = v.get("version").and_then(|v| v.as_str()).unwrap_or("");
    // Tolerate a leading `v` on the tarball's version (e.g. "v2.0.8").
    // Some publishers ship this shape; npm and bun normalize it on
    // install, so aube does too rather than rejecting a package the
    // other managers accept. The registry-side coordinate is the
    // source of truth, so we only normalize the tarball side.
    let actual_version_normalized = actual_version
        .strip_prefix('v')
        .filter(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
        .unwrap_or(actual_version);
    // npm registry metadata may omit semver build metadata that is
    // still present in the tarball's own package.json. Treat that as
    // the same version for the tarball-side normalization path: build
    // metadata does not affect semver precedence, and pnpm accepts
    // packages such as @trpc/react-query@11.0.0-rc.747 whose manifest
    // declares 11.0.0-rc.747+64714681c.
    let actual_version_without_build = actual_version_normalized
        .split_once('+')
        .map(|(base, _)| base);
    // pnpm v9 lockfiles key git-hosted deps by the codeload tarball URL
    // (or a `git+<url>#<commit>` form) in the `version` slot of the
    // dep_path — that URL is what the resolver hands us as
    // `expected_version`, and it can't meaningfully be compared to the
    // tarball's real semver. pnpm scopes its equivalent check to
    // registry sources; do the same by dropping the version comparison
    // (but still checking the name) whenever `expected_version` isn't
    // semver-shaped.
    let expected_is_url_or_ref = expected_version.contains("://")
        || expected_version.starts_with("git+")
        || expected_version.starts_with("file:");
    let version_matches = expected_is_url_or_ref
        || actual_version_normalized == expected_version
        || actual_version_without_build == Some(expected_version);
    if actual_name != expected_name || !version_matches {
        // Only carry the *actual* coordinate the tarball declared.
        // Every caller wraps the error with the expected
        // `{name}@{version}: ` prefix (mirroring the Error::Integrity
        // path), so embedding `expected` here would print the same
        // coordinate twice in the rendered diagnostic.
        return Err(Error::PkgContentMismatch {
            actual: format!("{actual_name}@{actual_version}"),
        });
    }
    Ok(())
}

/// Decode a pnpm-style SRI integrity string (`sha512-` / `sha384-` /
/// `sha256-` / `sha1-` + base64) into its raw hex digest. Used by
/// introspection commands that accept the registry integrity format
/// as an ergonomic input, and by `index_path` to shard the cache
/// directory by integrity prefix. Returns `None` if the input isn't a
/// well-formed SRI integrity string.
pub fn integrity_to_hex(integrity: &str) -> Option<String> {
    let (_, b64) = parse_sri(integrity)?;
    use base64::Engine;
    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
    Some(hex::encode(bytes))
}

/// Convert a legacy `dist.shasum` (hex-encoded SHA-1) into an SRI
/// `sha1-<base64>` integrity string — the inverse of the sha1 branch of
/// [`integrity_to_hex`]. Registries that predate npm's 2017 SRI rollout,
/// or proxies that strip the modern `dist.integrity` field, still ship
/// `dist.shasum`, and npm's classic client has always treated it as the
/// package's verification hash. Deriving `sha1-…` from it lets the
/// resolver record a verifiable integrity instead of falling through to
/// an unverifiable tarball-only resolution. Returns `None` unless the
/// input is a well-formed 40-char hex digest, so a malformed/absent
/// field cleanly degrades to the existing no-integrity path rather than
/// minting a bogus SRI.
pub fn shasum_to_sri(shasum: &str) -> Option<String> {
    let shasum = shasum.trim();
    if shasum.len() != 40 || !shasum.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    let bytes = hex::decode(shasum).ok()?;
    use base64::Engine;
    Some(format!(
        "sha1-{}",
        base64::engine::general_purpose::STANDARD.encode(bytes)
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn shasum_to_sri_matches_npm_classic_encoding() {
        // The exact value yarn's classic resolver computed for the
        // package in discussion #979: hex sha1 -> `sha1-<base64>`.
        assert_eq!(
            shasum_to_sri("dc96d6d3268bbc55103ce55cd4608d43d3b7ff72").as_deref(),
            Some("sha1-3JbW0yaLvFUQPOVc1GCNQ9O3/3I=")
        );
    }

    #[test]
    fn shasum_to_sri_round_trips_through_integrity_to_hex() {
        let hex = "dc96d6d3268bbc55103ce55cd4608d43d3b7ff72";
        let sri = shasum_to_sri(hex).unwrap();
        assert_eq!(integrity_to_hex(&sri).as_deref(), Some(hex));
    }

    #[test]
    fn shasum_to_sri_is_lenient_about_surrounding_whitespace() {
        assert!(shasum_to_sri("  dc96d6d3268bbc55103ce55cd4608d43d3b7ff72\n").is_some());
    }

    #[test]
    fn shasum_to_sri_rejects_malformed_input() {
        // Wrong length (sha256-sized), non-hex, and empty all degrade to
        // None so callers fall back to the no-integrity path instead of
        // recording a bogus SRI.
        assert_eq!(shasum_to_sri(&"a".repeat(64)), None);
        assert_eq!(
            shasum_to_sri("dc96d6d3268bbc55103ce55cd4608d43d3b7ffzz"),
            None
        );
        assert_eq!(shasum_to_sri(""), None);
    }
}