sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 49f38b4765e8afd432b6f45ab03bc500818582f1
parent d994ba4de5d25b207489fc760eebc91c52dacf1a
Author: triesap <tyson@radroots.org>
Date:   Sun, 28 Jun 2026 06:16:33 +0000

packages: inspect packed manifests

- read package.json from each pnpm-produced tarball
- validate packed package identity exports and metadata
- require internal workspace dependencies to publish as semver ranges
- cover accepted and rejected packed dependency rewrites in xtask tests

Diffstat:
MCargo.lock | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/Cargo.toml | 2++
Mtools/xtask/src/check.rs | 317++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 390 insertions(+), 3 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3,6 +3,12 @@ version = 4 [[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] name = "aead" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -410,6 +416,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] name = "crossbeam-channel" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -615,12 +630,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] name = "flume" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1296,6 +1331,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] name = "mio" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2105,6 +2150,7 @@ version = "0.1.0" dependencies = [ "dto_bindgen_backend_ts", "dto_bindgen_core", + "flate2", "radroots_core", "radroots_events", "radroots_events_bindings", @@ -2115,6 +2161,7 @@ dependencies = [ "radroots_types_bindings", "serde", "serde_json", + "tar", "tempfile", "toml", ] @@ -2637,6 +2684,12 @@ dependencies = [ ] [[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2821,6 +2874,17 @@ dependencies = [ ] [[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3861,6 +3925,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] name = "yaml-rust2" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -24,7 +24,9 @@ radroots_identity_bindings = { path = "../../crates/identity_bindings" } radroots_replica_db_schema_bindings = { path = "../../crates/replica_db_schema_bindings" } radroots_types_bindings = { path = "../../crates/types_bindings" } radroots_trade_bindings = { path = "../../crates/trade_bindings" } +flate2 = "1" serde_json = "1" serde = { workspace = true, features = ["derive"] } +tar = "0.4" tempfile = { workspace = true } toml = "0.8" diff --git a/tools/xtask/src/check.rs b/tools/xtask/src/check.rs @@ -1,11 +1,14 @@ use std::{ collections::BTreeSet, fs, + io::Read, path::{Path, PathBuf}, process::Command, }; +use flate2::read::GzDecoder; use serde::Deserialize; +use tar::Archive; use crate::{ contracts::validate_sdk_contracts, @@ -495,6 +498,14 @@ fn check_npm_pack_payloads(root: &Path) -> Result<(), String> { let json = read_package_json_value(&package_json_path)?; let required_files = required_npm_payload_files(&json, &package_json_path, None)?; let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?; + let packed_json = read_packed_package_json(&packed, spec.package_name)?; + check_packed_package_json( + &json, + &packed_json, + &package_json_path, + spec.package_name, + spec.package_dir, + )?; let payload_files = packed_payload_files(&packed); validate_npm_pack_payload(spec.package_name, &payload_files, &required_files, None)?; } @@ -504,6 +515,14 @@ fn check_npm_pack_payloads(root: &Path) -> Result<(), String> { let json = read_package_json_value(&package_json_path)?; let required_files = required_npm_payload_files(&json, &package_json_path, Some(*spec))?; let packed = pnpm_pack_package(&package_dir, spec.package_name, pack_dir.path())?; + let packed_json = read_packed_package_json(&packed, spec.package_name)?; + check_packed_package_json( + &json, + &packed_json, + &package_json_path, + spec.package_name, + spec.package_dir, + )?; let payload_files = packed_payload_files(&packed); validate_npm_pack_payload( spec.package_name, @@ -658,6 +677,213 @@ fn packed_payload_files(packed: &PackedPackage) -> BTreeSet<String> { .collect() } +fn read_packed_package_json( + packed: &PackedPackage, + package_name: &str, +) -> Result<serde_json::Value, String> { + let file = fs::File::open(&packed.tarball_path).map_err(|error| { + format!( + "failed to open pnpm pack tarball for {package_name}: {}: {error}", + packed.tarball_path.display() + ) + })?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let entries = archive.entries().map_err(|error| { + format!( + "failed to read pnpm pack tarball entries for {package_name}: {}: {error}", + packed.tarball_path.display() + ) + })?; + for entry in entries { + let mut entry = entry.map_err(|error| { + format!( + "failed to read pnpm pack tarball entry for {package_name}: {}: {error}", + packed.tarball_path.display() + ) + })?; + let path = entry.path().map_err(|error| { + format!( + "failed to read pnpm pack tarball entry path for {package_name}: {}: {error}", + packed.tarball_path.display() + ) + })?; + if path.as_ref() != Path::new("package/package.json") { + continue; + } + let mut raw = String::new(); + entry.read_to_string(&mut raw).map_err(|error| { + format!( + "failed to read packed package.json for {package_name}: {}: {error}", + packed.tarball_path.display() + ) + })?; + return serde_json::from_str::<serde_json::Value>(&raw).map_err(|error| { + format!( + "failed to parse packed package.json for {package_name}: {}: {error}", + packed.tarball_path.display() + ) + }); + } + Err(format!( + "pnpm pack tarball for {package_name} is missing package/package.json: {}", + packed.tarball_path.display() + )) +} + +fn check_packed_package_json( + source_json: &serde_json::Value, + packed_json: &serde_json::Value, + package_json_path: &Path, + expected_name: &str, + expected_directory: &str, +) -> Result<(), String> { + let source_description = package_description(source_json, package_json_path)?; + let packed_description = package_description(packed_json, package_json_path)?; + if packed_description != source_description { + return Err(format!( + "packed package.json description mismatch in {}: expected {source_description}, found {packed_description}", + package_json_path.display() + )); + } + require_string_field(packed_json, package_json_path, "name", expected_name)?; + require_string_field(packed_json, package_json_path, "version", PACKAGE_VERSION)?; + require_string_field(packed_json, package_json_path, "license", PACKAGE_LICENSE)?; + require_string_field(packed_json, package_json_path, "homepage", PACKAGE_HOMEPAGE)?; + require_string_field(packed_json, package_json_path, "type", "module")?; + require_bool_field(packed_json, package_json_path, "sideEffects", false)?; + check_publish_config(packed_json, package_json_path)?; + check_repository(packed_json, package_json_path, expected_directory)?; + check_package_files(packed_json, package_json_path)?; + check_no_pack_lifecycle_scripts(packed_json, package_json_path)?; + check_same_packed_field(source_json, packed_json, package_json_path, "main")?; + check_same_packed_field(source_json, packed_json, package_json_path, "types")?; + check_same_packed_field(source_json, packed_json, package_json_path, "exports")?; + check_same_packed_field(source_json, packed_json, package_json_path, "scripts")?; + check_packed_dependency_maps(source_json, packed_json, package_json_path)?; + let source_surface = package_surface_paths(source_json, package_json_path)?; + let packed_surface = package_surface_paths(packed_json, package_json_path)?; + if packed_surface != source_surface { + return Err(format!( + "packed package.json export surface mismatch in {}", + package_json_path.display() + )); + } + Ok(()) +} + +fn check_same_packed_field( + source_json: &serde_json::Value, + packed_json: &serde_json::Value, + package_json_path: &Path, + field: &'static str, +) -> Result<(), String> { + if source_json.get(field) != packed_json.get(field) { + return Err(format!( + "packed package.json {field} mismatch in {}", + package_json_path.display() + )); + } + Ok(()) +} + +fn check_packed_dependency_maps( + source_json: &serde_json::Value, + packed_json: &serde_json::Value, + package_json_path: &Path, +) -> Result<(), String> { + for field in ["dependencies", "peerDependencies", "optionalDependencies"] { + check_packed_dependency_map(source_json, packed_json, package_json_path, field)?; + } + Ok(()) +} + +fn check_packed_dependency_map( + source_json: &serde_json::Value, + packed_json: &serde_json::Value, + package_json_path: &Path, + field: &'static str, +) -> Result<(), String> { + let source_dependencies = optional_dependency_map(source_json, package_json_path, field)?; + let packed_dependencies = optional_dependency_map(packed_json, package_json_path, field)?; + let source_keys = source_dependencies + .iter() + .flat_map(|dependencies| dependencies.keys().cloned()) + .collect::<BTreeSet<_>>(); + let packed_keys = packed_dependencies + .iter() + .flat_map(|dependencies| dependencies.keys().cloned()) + .collect::<BTreeSet<_>>(); + if packed_keys != source_keys { + return Err(format!( + "packed package.json {field} keys mismatch in {}: expected {:?}, found {:?}", + package_json_path.display(), + source_keys, + packed_keys + )); + } + let Some(packed_dependencies) = packed_dependencies else { + return Ok(()); + }; + let source_dependencies = source_dependencies.expect("matching dependency keys require source"); + for (name, packed_version) in packed_dependencies { + let packed_version = packed_version.as_str().ok_or_else(|| { + format!( + "packed package.json {field} dependency versions must be strings in {}", + package_json_path.display() + ) + })?; + if packed_version.starts_with("workspace:") { + return Err(format!( + "packed package.json {field} dependency {name} must not use workspace protocol in {}", + package_json_path.display() + )); + } + let source_version = source_dependencies + .get(name) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "source package.json {field} dependency {name} must be a string in {}", + package_json_path.display() + ) + })?; + let expected = expected_packed_dependency_version(name, source_version); + if packed_version != expected { + return Err(format!( + "packed package.json {field} dependency {name} mismatch in {}: expected {expected}, found {packed_version}", + package_json_path.display() + )); + } + } + Ok(()) +} + +fn optional_dependency_map<'a>( + json: &'a serde_json::Value, + package_json_path: &Path, + field: &'static str, +) -> Result<Option<&'a serde_json::Map<String, serde_json::Value>>, String> { + json.get(field) + .map(|value| { + value.as_object().ok_or_else(|| { + format!( + "package.json {field} must be an object: {}", + package_json_path.display() + ) + }) + }) + .transpose() +} + +fn expected_packed_dependency_version(name: &str, source_version: &str) -> String { + if name.starts_with("@radroots/") && source_version == "workspace:^" { + format!("^{PACKAGE_VERSION}") + } else { + source_version.to_owned() + } +} + fn validate_npm_pack_payload( package_name: &str, payload_files: &BTreeSet<String>, @@ -924,7 +1150,7 @@ mod tests { use std::{ collections::BTreeSet, fs, - path::PathBuf, + path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; @@ -937,8 +1163,8 @@ mod tests { use super::{ 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, - parse_pnpm_pack_entry, validate_npm_pack_payload, + check_package_json, check_package_surface_artifacts, check_packed_package_json, + check_wasm_package_surface, parse_pnpm_pack_entry, validate_npm_pack_payload, }; #[test] @@ -1223,6 +1449,75 @@ mod tests { } #[test] + fn packed_manifest_accepts_pnpm_workspace_dependency_rewrite() { + let source = package_json_value(&package_json_with_dependencies( + "trade-bindings", + r#""@radroots/core-bindings": "workspace:^", + "@radroots/events-bindings": "workspace:^""#, + )); + let packed = package_json_value(&package_json_with_dependencies( + "trade-bindings", + r#""@radroots/core-bindings": "^0.1.0", + "@radroots/events-bindings": "^0.1.0""#, + )); + + check_packed_package_json( + &source, + &packed, + Path::new("packages/trade-bindings/package.json"), + "@radroots/trade-bindings", + "packages/trade-bindings", + ) + .expect("packed manifest accepted"); + } + + #[test] + fn packed_manifest_rejects_workspace_dependency_ranges() { + let source = package_json_value(&package_json_with_dependencies( + "trade-bindings", + r#""@radroots/core-bindings": "workspace:^""#, + )); + let packed = package_json_value(&package_json_with_dependencies( + "trade-bindings", + r#""@radroots/core-bindings": "workspace:^""#, + )); + + let error = check_packed_package_json( + &source, + &packed, + Path::new("packages/trade-bindings/package.json"), + "@radroots/trade-bindings", + "packages/trade-bindings", + ) + .expect_err("workspace dependency rejected"); + + assert!(error.contains("must not use workspace protocol")); + } + + #[test] + fn packed_manifest_rejects_internal_dependency_range_mismatch() { + let source = package_json_value(&package_json_with_dependencies( + "trade-bindings", + r#""@radroots/core-bindings": "workspace:^""#, + )); + let packed = package_json_value(&package_json_with_dependencies( + "trade-bindings", + r#""@radroots/core-bindings": "^0.2.0""#, + )); + + let error = check_packed_package_json( + &source, + &packed, + Path::new("packages/trade-bindings/package.json"), + "@radroots/trade-bindings", + "packages/trade-bindings", + ) + .expect_err("internal dependency mismatch rejected"); + + assert!(error.contains("expected ^0.1.0")); + } + + #[test] fn npm_pack_payload_rejects_source_and_provenance_internals() { let required_files = BTreeSet::from(["package.json".to_owned()]); for forbidden in [ @@ -1578,4 +1873,20 @@ mod tests { }}"# ) } + + fn package_json_with_dependencies(name: &str, dependencies: &str) -> String { + let raw = package_json(name); + let body = raw.strip_suffix('}').expect("root package JSON object"); + format!( + r#"{body}, + "dependencies": {{ + {dependencies} + }} +}}"# + ) + } + + fn package_json_value(raw: &str) -> serde_json::Value { + serde_json::from_str(raw).expect("package json parses") + } }