commit d994ba4de5d25b207489fc760eebc91c52dacf1a
parent 71d53d141f8d2dd68bf3c049a0e0d88873ab0fb3
Author: triesap <tyson@radroots.org>
Date: Sun, 28 Jun 2026 06:12:37 +0000
packages: use pnpm pack tarballs
- route package payload proof through pnpm pack JSON output
- validate pnpm tarball paths under a temporary pack directory
- reject pack lifecycle scripts that pnpm would execute
- cover pnpm pack parsing and lifecycle-script rejection in xtask tests
Diffstat:
3 files changed, 170 insertions(+), 22 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2115,6 +2115,7 @@ dependencies = [
"radroots_types_bindings",
"serde",
"serde_json",
+ "tempfile",
"toml",
]
diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml
@@ -26,4 +26,5 @@ radroots_types_bindings = { path = "../../crates/types_bindings" }
radroots_trade_bindings = { path = "../../crates/trade_bindings" }
serde_json = "1"
serde = { workspace = true, features = ["derive"] }
+tempfile = { workspace = true }
toml = "0.8"
diff --git a/tools/xtask/src/check.rs b/tools/xtask/src/check.rs
@@ -1,4 +1,9 @@
-use std::{collections::BTreeSet, fs, path::Path, process::Command};
+use std::{
+ collections::BTreeSet,
+ fs,
+ path::{Path, PathBuf},
+ process::Command,
+};
use serde::Deserialize;
@@ -22,11 +27,23 @@ const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.gi
const PUBLISH_ACCESS: &str = "public";
#[derive(Debug, Deserialize)]
-struct NpmPackEntry {
- files: Vec<NpmPackFile>,
+struct PnpmPackEntry {
+ filename: String,
+ files: Vec<PnpmPackFile>,
}
#[derive(Debug, Deserialize)]
+struct PnpmPackFile {
+ path: String,
+}
+
+#[derive(Debug)]
+struct PackedPackage {
+ tarball_path: PathBuf,
+ files: Vec<NpmPackFile>,
+}
+
+#[derive(Debug)]
struct NpmPackFile {
path: String,
}
@@ -254,6 +271,7 @@ fn check_package_json(
check_publish_config(&json, path)?;
check_repository(&json, path, expected_directory)?;
check_package_files(&json, path)?;
+ check_no_pack_lifecycle_scripts(&json, path)?;
check_workspace_dependencies(&json, path)?;
Ok(json)
}
@@ -469,12 +487,15 @@ fn check_workspace_dependencies(
}
fn check_npm_pack_payloads(root: &Path) -> Result<(), String> {
+ let pack_dir =
+ tempfile::tempdir().map_err(|error| format!("failed to create pack temp dir: {error}"))?;
for spec in package_specs() {
let package_dir = root.join(spec.package_dir);
let package_json_path = package_dir.join("package.json");
let json = read_package_json_value(&package_json_path)?;
let required_files = required_npm_payload_files(&json, &package_json_path, None)?;
- let payload_files = npm_pack_payload_files(&package_dir, spec.package_name)?;
+ let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?;
+ let payload_files = packed_payload_files(&packed);
validate_npm_pack_payload(spec.package_name, &payload_files, &required_files, None)?;
}
for spec in wasm_package_specs() {
@@ -482,7 +503,8 @@ fn check_npm_pack_payloads(root: &Path) -> Result<(), String> {
let package_json_path = package_dir.join("package.json");
let json = read_package_json_value(&package_json_path)?;
let required_files = required_npm_payload_files(&json, &package_json_path, Some(*spec))?;
- let payload_files = npm_pack_payload_files(&package_dir, spec.package_name)?;
+ let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?;
+ let payload_files = packed_payload_files(&packed);
validate_npm_pack_payload(
spec.package_name,
&payload_files,
@@ -493,6 +515,36 @@ fn check_npm_pack_payloads(root: &Path) -> Result<(), String> {
Ok(())
}
+fn check_no_pack_lifecycle_scripts(
+ json: &serde_json::Value,
+ package_json_path: &Path,
+) -> Result<(), String> {
+ let Some(scripts) = json.get("scripts") else {
+ return Ok(());
+ };
+ let scripts = scripts.as_object().ok_or_else(|| {
+ format!(
+ "package.json scripts must be an object: {}",
+ package_json_path.display()
+ )
+ })?;
+ for forbidden in [
+ "prepack",
+ "postpack",
+ "prepare",
+ "prepublish",
+ "prepublishOnly",
+ ] {
+ if scripts.contains_key(forbidden) {
+ return Err(format!(
+ "package.json script {forbidden} is forbidden because pnpm pack runs lifecycle scripts: {}",
+ package_json_path.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
fn read_package_json_value(path: &Path) -> Result<serde_json::Value, String> {
let raw = fs::read_to_string(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
@@ -522,44 +574,88 @@ fn required_npm_payload_files(
Ok(required)
}
-fn npm_pack_payload_files(
+fn pnpm_pack_package(
package_dir: &Path,
package_name: &str,
-) -> Result<BTreeSet<String>, String> {
- let output = Command::new("npm")
- .args(["pack", "--json", "--dry-run", "--ignore-scripts"])
+ pack_destination: &Path,
+) -> Result<PackedPackage, String> {
+ let output = Command::new("pnpm")
+ .args(["pack", "--json", "--pack-destination"])
+ .arg(pack_destination)
.current_dir(package_dir)
.output()
.map_err(|error| {
format!(
- "failed to run npm pack dry-run for {package_name} in {}: {error}",
+ "failed to run pnpm pack for {package_name} in {}: {error}",
package_dir.display()
)
})?;
if !output.status.success() {
return Err(format!(
- "npm pack dry-run failed for {package_name} in {}: {}",
+ "pnpm pack failed for {package_name} in {}: {}",
package_dir.display(),
String::from_utf8_lossy(&output.stderr)
));
}
- let entries = serde_json::from_slice::<Vec<NpmPackEntry>>(&output.stdout).map_err(|error| {
+ let entry = parse_pnpm_pack_entry(package_name, &output.stdout, &output.stderr)?;
+ packed_package_from_pnpm_entry(package_name, pack_destination, entry)
+}
+
+fn parse_pnpm_pack_entry(
+ package_name: &str,
+ stdout: &[u8],
+ stderr: &[u8],
+) -> Result<PnpmPackEntry, String> {
+ serde_json::from_slice::<PnpmPackEntry>(stdout).map_err(|error| {
format!(
- "failed to parse npm pack dry-run output for {package_name}: {error}; stdout: {}; stderr: {}",
- String::from_utf8_lossy(&output.stdout),
- String::from_utf8_lossy(&output.stderr)
+ "failed to parse pnpm pack output for {package_name}: {error}; stdout: {}; stderr: {}",
+ String::from_utf8_lossy(stdout),
+ String::from_utf8_lossy(stderr)
)
- })?;
- let [entry] = entries.as_slice() else {
+ })
+}
+
+fn packed_package_from_pnpm_entry(
+ package_name: &str,
+ pack_destination: &Path,
+ entry: PnpmPackEntry,
+) -> Result<PackedPackage, String> {
+ if entry.filename.trim().is_empty() {
return Err(format!(
- "npm pack dry-run for {package_name} must return one package entry"
+ "pnpm pack output for {package_name} is missing tarball filename"
));
- };
- Ok(entry
+ }
+ let tarball_path = PathBuf::from(&entry.filename);
+ if !tarball_path.starts_with(pack_destination) {
+ return Err(format!(
+ "pnpm pack tarball for {package_name} must be written under {}: {}",
+ pack_destination.display(),
+ tarball_path.display()
+ ));
+ }
+ if !tarball_path.is_file() {
+ return Err(format!(
+ "pnpm pack tarball for {package_name} does not exist: {}",
+ tarball_path.display()
+ ));
+ }
+ Ok(PackedPackage {
+ tarball_path,
+ files: entry
+ .files
+ .into_iter()
+ .map(|file| NpmPackFile { path: file.path })
+ .collect(),
+ })
+}
+
+fn packed_payload_files(packed: &PackedPackage) -> BTreeSet<String> {
+ debug_assert!(packed.tarball_path.is_file());
+ packed
.files
.iter()
.map(|file| normalized_package_path(&file.path))
- .collect())
+ .collect()
}
fn validate_npm_pack_payload(
@@ -842,7 +938,7 @@ mod tests {
check_binding_crate_sources, check_generated_package_artifact_inventory,
check_no_typescript_files, check_package_distribution_metadata, check_package_index,
check_package_json, check_package_surface_artifacts, check_wasm_package_surface,
- validate_npm_pack_payload,
+ parse_pnpm_pack_entry, validate_npm_pack_payload,
};
#[test]
@@ -970,6 +1066,28 @@ mod tests {
}
#[test]
+ fn public_package_metadata_rejects_pack_lifecycle_scripts() {
+ let root = test_root("pack_lifecycle_scripts");
+ let package_dir = root.join("packages").join("example");
+ fs::create_dir_all(&package_dir).expect("create package");
+ let package_json = package_json("example").replace(
+ r#""type": "module","#,
+ r#""scripts": {"prepack": "echo forbidden"}, "type": "module","#,
+ );
+ fs::write(package_dir.join("package.json"), package_json).expect("write package json");
+
+ let error = check_package_json(
+ &package_dir.join("package.json"),
+ "@radroots/example",
+ "packages/example",
+ )
+ .expect_err("pack lifecycle script rejected");
+
+ assert!(error.contains("script prepack is forbidden"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
fn package_distribution_metadata_matches_root_license_files() {
let root = test_root("package_distribution_metadata");
let package_dir = root.join("packages").join("example");
@@ -1077,6 +1195,34 @@ mod tests {
}
#[test]
+ fn pnpm_pack_json_parser_accepts_single_package_object() {
+ let entry = parse_pnpm_pack_entry(
+ "@radroots/example",
+ br#"{
+ "name": "@radroots/example",
+ "version": "0.1.0",
+ "filename": "/tmp/example.tgz",
+ "files": [
+ {"path": "dist/index.js"},
+ {"path": "package.json"}
+ ]
+}"#,
+ b"",
+ )
+ .expect("pnpm pack output parses");
+
+ assert_eq!(entry.filename, "/tmp/example.tgz");
+ assert_eq!(
+ entry
+ .files
+ .into_iter()
+ .map(|file| file.path)
+ .collect::<Vec<_>>(),
+ ["dist/index.js", "package.json"]
+ );
+ }
+
+ #[test]
fn npm_pack_payload_rejects_source_and_provenance_internals() {
let required_files = BTreeSet::from(["package.json".to_owned()]);
for forbidden in [