Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 131 additions & 1 deletion crate_universe/src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::process::Command;

use anyhow::{bail, Context as AnyhowContext, Result};
use hex::ToHex;
use once_cell::sync::OnceCell;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest as Sha2Digest, Sha256};

Expand Down Expand Up @@ -103,6 +105,33 @@ impl Digest {
hash
}

/// Normalize canonical Bazel labels in a JSON string for stable digest computation.
///
/// The canonical name of a repository changes depending on whether it is used as the
/// root workspace/module or as a dependency:
///
/// - bzlmod root: `@@//package:target`
/// - bzlmod non-root: `@@module_name+//package:target`
/// - WORKSPACE root: `@@//package:target` (or `//package:target`)
/// - WORKSPACE dep: `@@repo_name//package:target`
///
/// To ensure the lockfile digest is stable across these contexts (so that a lockfile
/// generated when a module is root remains valid when the module is used as a
/// non-root dependency), we normalize all canonical labels by stripping the
/// repository name component, treating `@@repo_name//` the same as `@@//`.
fn normalize_labels_for_digest(json: &str) -> std::borrow::Cow<'_, str> {
static RE: OnceCell<Regex> = OnceCell::new();
let re = RE.get_or_init(|| {
// Match canonical labels (@@...) with a non-empty repository name followed by //.
// The repository name is matched by [^/"]+ (one or more chars that are not / or ").
// This handles both bzlmod (@@module_name+//) and WORKSPACE (@@repo_name//) forms.
// This does NOT match @@// (root form, empty repo name) since [^/"]+ requires at
// least one character.
Regex::new(r#"@@[^/"]+//"#).expect("valid regex")
});
re.replace_all(json, "@@//")
}

fn compute(
context: &Context,
config: &Config,
Expand Down Expand Up @@ -132,8 +161,10 @@ impl Digest {
hasher.update(b"\0");

// This content is generated by various attributes in Bazel rules and written to a file behind the scenes.
// Labels are normalized to strip bzlmod canonical repository names so the digest is stable
// regardless of whether the module is used as a root module or a non-root dependency.
hasher.update(Digest::compute_single_hash(
&serde_json::to_string(config).unwrap(),
&Self::normalize_labels_for_digest(&serde_json::to_string(config).unwrap()),
"workspace config",
));
hasher.update(b"\0");
Expand Down Expand Up @@ -429,4 +460,103 @@ mod test {
"Digests should be identical regardless of CRLF vs LF line endings in cargo_config"
);
}

#[test]
fn digest_stable_for_root_vs_non_root_module() {
// Verifies that the digest is stable when labels in the config use different
// canonical forms depending on whether the module is root (@@//) or a non-root
// dependency (@@module_name+//). See: https://github.com/bazelbuild/rules_rust/issues/3521
let context = Context::default();
let splicing_metadata = SplicingMetadata::default();

let make_config = |patch_label: &str| -> Config {
Config {
annotations: BTreeMap::from([(
CrateNameAndVersionReq::new("some_crate".to_owned(), "1.0.0".parse().unwrap()),
CrateAnnotations {
patches: Some(BTreeSet::from([patch_label.to_owned()])),
..CrateAnnotations::default()
},
)]),
..Config::default()
}
};

// Same patch label in root vs non-root bzlmod context
let config_root = make_config("@@//patches/my_crate.patch");
let config_non_root = make_config("@@module1+//patches/my_crate.patch");

let digest_root = Digest::compute(
&context,
&config_root,
&splicing_metadata,
"0.1.0",
"cargo 1.57.0 (b2e52d7ca 2021-10-21)",
"rustc 1.57.0 (f1edd0429 2021-11-29)",
);

let digest_non_root = Digest::compute(
&context,
&config_non_root,
&splicing_metadata,
"0.1.0",
"cargo 1.57.0 (b2e52d7ca 2021-10-21)",
"rustc 1.57.0 (f1edd0429 2021-11-29)",
);

assert_eq!(
digest_root, digest_non_root,
"Digests should be identical for root (@@//...) and non-root (@@module_name+//...) module contexts"
);
}

#[test]
fn digest_stable_for_workspace_root_vs_dep() {
// Verifies that the digest is stable in legacy WORKSPACE mode, where labels also
// change canonical form when the workspace transitions from root to an external dep:
// - Root workspace: @@//package:target
// - External workspace dep: @@repo_name//package:target (no "+" suffix unlike bzlmod)
let context = Context::default();
let splicing_metadata = SplicingMetadata::default();

let make_config = |patch_label: &str| -> Config {
Config {
annotations: BTreeMap::from([(
CrateNameAndVersionReq::new("some_crate".to_owned(), "1.0.0".parse().unwrap()),
CrateAnnotations {
patches: Some(BTreeSet::from([patch_label.to_owned()])),
..CrateAnnotations::default()
},
)]),
..Config::default()
}
};

// Same patch label in WORKSPACE root vs external dep context (no "+" in repo name)
let config_root = make_config("@@//patches/my_crate.patch");
let config_dep = make_config("@@my_workspace//patches/my_crate.patch");

let digest_root = Digest::compute(
&context,
&config_root,
&splicing_metadata,
"0.1.0",
"cargo 1.57.0 (b2e52d7ca 2021-10-21)",
"rustc 1.57.0 (f1edd0429 2021-11-29)",
);

let digest_dep = Digest::compute(
&context,
&config_dep,
&splicing_metadata,
"0.1.0",
"cargo 1.57.0 (b2e52d7ca 2021-10-21)",
"rustc 1.57.0 (f1edd0429 2021-11-29)",
);

assert_eq!(
digest_root, digest_dep,
"Digests should be identical for WORKSPACE root (@@//...) and dep (@@repo_name//...) contexts"
);
}
}