diff --git a/.claude/skills/weave-issue/SKILL.md b/.claude/skills/weave-issue/SKILL.md index 68ef853..5b4d60a 100644 --- a/.claude/skills/weave-issue/SKILL.md +++ b/.claude/skills/weave-issue/SKILL.md @@ -76,30 +76,30 @@ Do not create duplicate issues — check the open issues list above first. No "B ### Step 6 — Set issue type -Get the new issue's node ID and set the type: +Get the new issue's node ID and set the type **in a single Bash call** (variables don't persist across calls): ```sh -# Get the issue node ID -NODE_ID=$(gh api graphql -f query='{ repository(owner: "PackWeave", name: "weave") { issue(number: ) { id } } }' --jq '.data.repository.issue.id') - -# Set the type (use the type ID from context above) +NODE_ID=$(gh api graphql -f query='{ repository(owner: "PackWeave", name: "weave") { issue(number: ) { id } } }' --jq '.data.repository.issue.id') && \ gh api graphql -f query='mutation { updateIssue(input: {id: "'$NODE_ID'", issueTypeId: ""}) { issue { number } } }' ``` ### Step 7 — Set blocked-by relationships -If blocking issues were identified in Step 4, for each blocker: +If blocking issues were identified in Step 4, for each blocker run **in a single Bash call** (NODE_ID from Step 6 won't persist — re-fetch it, or chain all steps together): ```sh -# Get the blocker's node ID -BLOCKER_ID=$(gh api graphql -f query='{ repository(owner: "PackWeave", name: "weave") { issue(number: ) { id } } }' --jq '.data.repository.issue.id') - -# Set the relationship -gh api graphql -f query='mutation { addBlockedBy(input: {issueId: "'$NODE_ID'", blockingIssueId: "'$BLOCKER_ID'"}) { issue { number } blockingIssue { number } } }' +NODE_ID=$(gh api graphql -f query='{ repository(owner: "PackWeave", name: "weave") { issue(number: ) { id } } }' --jq '.data.repository.issue.id') && \ +BLOCKER_ID=$(gh api graphql -f query='{ repository(owner: "PackWeave", name: "weave") { issue(number: ) { id } } }' --jq '.data.repository.issue.id') && \ +gh api graphql -f query='mutation { addBlockedBy(input: {issueId: "'$NODE_ID'", blockingIssueId: "'$BLOCKER_ID'"}) { issue { number } blockingIssue { number } } }' && \ +gh issue edit --add-label "blocked" ``` -Also add the `blocked` label if any blockers were set: +Repeat for each additional blocker, or loop: ```sh +for BLOCKER_NUM in ; do + BLOCKER_ID=$(gh api graphql -f query='{ repository(owner: "PackWeave", name: "weave") { issue(number: '$BLOCKER_NUM') { id } } }' --jq '.data.repository.issue.id') && \ + gh api graphql -f query='mutation { addBlockedBy(input: {issueId: "'$NODE_ID'", blockingIssueId: "'$BLOCKER_ID'"}) { issue { number } blockingIssue { number } } }' +done gh issue edit --add-label "blocked" ``` diff --git a/AGENTS.md b/AGENTS.md index b5b1996..3dcf9f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,7 @@ src/core/ Business logic — no I/O to CLI config files here core/store.rs Local pack cache (~/.packweave/packs/) core/registry.rs Registry trait + GitHubRegistry + CompositeRegistry core/mcp_registry.rs MCP Registry client (weave search --mcp) + core/checksum.rs SHA-256 pack content integrity verification core/conflict.rs Tool-level conflict detection core/install.rs Install orchestration (registry + local) core/update.rs Update orchestration (version comparison + apply) diff --git a/Cargo.lock b/Cargo.lock index f67ba28..a15b339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -247,6 +256,25 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -271,6 +299,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -482,6 +520,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1002,6 +1050,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "tempfile", "thiserror", "tokio", @@ -1572,6 +1621,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1873,6 +1933,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1915,6 +1981,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 26679b9..96cd5be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" thiserror = "2" +sha2 = "0.10" toml = "0.8" toml_edit = "0.22" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3f22835..334563f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,6 +35,9 @@ weave install @webdev Resolver ← resolves semver, checks conflicts, builds install plan │ ▼ + Checksum ← verifies SHA-256 content integrity before storing + │ + ▼ Store ← writes inline files to ~/.packweave/packs/ │ ▼ @@ -93,6 +96,7 @@ src/ mod.rs Module re-exports. config.rs Global weave config (~/.packweave/config.toml). credentials.rs Token storage, retrieval, and validation. + checksum.rs SHA-256 pack content integrity verification. conflict.rs Tool-level conflict detection across installed packs. install.rs Install orchestration (registry + local). lockfile.rs Lock file: read/write, version pinning. @@ -525,14 +529,15 @@ Each version entry contains a `files` map of relative path → file content: }, "dependencies": { "other-pack": "^1.0.0" - } + }, + "checksum": "sha256:a1b2c3..." } ``` The store writes each entry directly to `~/.packweave/packs/{name}/{version}/` after -path-validating the key (rejects absolute paths, `..` components, Windows drive prefixes). -Trust is provided by TLS and GitHub as the content host. No tarballs, no release artifacts, -no SHA256 ceremony. +path-validating the key (rejects absolute paths, `..` components, Windows drive prefixes) +and verifying the SHA-256 checksum embedded in the release metadata. No tarballs, no release +artifacts — integrity is verified via content-addressable checksums. ----- diff --git a/docs/REGISTRY.md b/docs/REGISTRY.md index fde8b38..421d839 100644 --- a/docs/REGISTRY.md +++ b/docs/REGISTRY.md @@ -8,7 +8,7 @@ This document is the authoritative specification for the PackWeave registry prot The pack registry is a GitHub-hosted repository (`PackWeave/registry`) that serves pack metadata and file content. It is separate from MCP server registries (like the official MCP Registry or Smithery) — weave packs are composable bundles of MCP server configuration, system prompts, slash commands, and settings, not individual MCP server listings. -The registry uses a two-tier sparse index so clients never download more than they need. Pack content is embedded directly in `packs/{name}.json` as a flat map of relative path → file content — no tarballs, no release artifacts, no SHA256 ceremony. +The registry uses a two-tier sparse index so clients never download more than they need. Pack content is embedded directly in `packs/{name}.json` as a flat map of relative path → file content — no tarballs, no release artifacts. Integrity is verified via SHA-256 checksums embedded in each release entry. --- @@ -89,13 +89,14 @@ Fetched on demand when installing or resolving a specific pack. Contains all ver "pack.toml": "[pack]\nname = \"filesystem\"\n...", "prompts/system.md": "# System prompt content...", "commands/review.md": "# Review command..." - } + }, + "checksum": "sha256:a1b2c3..." } ] } ``` -`files` is a flat map of relative path → file content. The store writes each entry directly to `~/.packweave/packs/{name}/{version}/` — no tarball download, no SHA256 verification step. Trust is provided by TLS and GitHub as the content host. +`files` is a flat map of relative path → file content. The store writes each entry directly to `~/.packweave/packs/{name}/{version}/` after verifying the SHA-256 checksum. No tarball download — integrity is ensured by content-addressable checksums. The client caches this per-pack after the first fetch for the lifetime of the command. @@ -108,10 +109,12 @@ weave install filesystem │ ├─ GET {base}/packs/filesystem.json │ ├─ Authorization: Bearer (if authenticated) - │ └─ {versions: [{version, files, dependencies}]} + │ └─ {versions: [{version, files, dependencies, checksum}]} │ ├─ resolve: pick version satisfying constraints │ + ├─ verify SHA-256 checksum of pack content + │ ├─ write files from release.files to ~/.packweave/packs/filesystem/0.1.0/ │ (path-validated: no .., no absolute paths) │ @@ -400,7 +403,8 @@ WEAVE_REGISTRY_URL=https://your-registry.example.com weave search mypack }, "files": { "": "file content string" - } + }, + "checksum": "sha256:" } ] } diff --git a/src/core/checksum.rs b/src/core/checksum.rs new file mode 100644 index 0000000..2f227c4 --- /dev/null +++ b/src/core/checksum.rs @@ -0,0 +1,282 @@ +//! Pack content integrity verification. +//! +//! Checksums are computed over the canonical JSON representation of a pack +//! release's `files` map (sorted keys, compact separators, raw UTF-8). Both +//! the registry generator (`scripts/generate.py`) and this module use the same +//! canonical form so hashes match cross-platform. +//! +//! **Important:** Checksums verify *transport integrity*, not *authorship*. A +//! compromised registry can produce valid checksums for malicious content. +//! Content signing (GPG/sigstore) would be a separate future feature. + +use std::collections::HashMap; + +use sha2::{Digest, Sha256}; + +use crate::error::{Result, WeaveError}; + +/// Algorithm prefix for SHA-256 checksums in the registry. +const SHA256_PREFIX: &str = "sha256:"; + +/// Compute a `sha256:` prefixed checksum over a pack's files map. +/// +/// The canonical form is compact sorted JSON with raw UTF-8 — equivalent to +/// Python's `json.dumps(files, sort_keys=True, separators=(',', ':'), ensure_ascii=False)`. +pub fn compute(files: &HashMap) -> String { + let canonical = canonical_json(files); + let hash = Sha256::digest(canonical.as_bytes()); + format!("{SHA256_PREFIX}{}", hex::encode(hash)) +} + +/// Verify a pack release's checksum. Returns `Ok(())` when: +/// - `expected` is `None` (old registry without checksums — warn and proceed) +/// - `expected` uses an unrecognized algorithm prefix (warn with upgrade hint) +/// - `expected` matches the computed checksum +/// +/// Returns `Err(ChecksumMismatch)` when the checksum is present and doesn't match. +pub fn verify( + pack_name: &str, + version: &semver::Version, + files: &HashMap, + expected: Option<&str>, +) -> Result<()> { + let expected = match expected { + Some(e) => e, + None => { + log::warn!( + "pack '{pack_name}' v{version} has no checksum in registry; \ + skipping integrity verification" + ); + return Ok(()); + } + }; + + // Only verify algorithms we understand; warn and skip for unknown ones + // so future algorithm upgrades don't break old clients. This is acceptable + // because a None checksum (omitted field) has the same skip behavior, so + // an attacker gains nothing from setting an unknown algorithm that they + // couldn't already achieve by stripping the field entirely. + if !expected.starts_with(SHA256_PREFIX) { + let algo = expected.split(':').next().unwrap_or("unknown"); + log::warn!( + "pack '{pack_name}' v{version} uses checksum algorithm '{algo}' which \ + this version of weave does not support; skipping integrity verification \ + — upgrade weave to verify: cargo install packweave" + ); + return Ok(()); + } + + let actual = compute(files); + if actual != expected { + return Err(WeaveError::ChecksumMismatch { + pack_name: pack_name.to_string(), + version: version.clone(), + expected: expected.to_string(), + actual, + }); + } + + log::debug!("pack '{pack_name}' v{version} checksum verified"); + Ok(()) +} + +/// Produce compact sorted JSON for the files map — the canonical input to hash. +/// +/// Equivalent to Python's `json.dumps(files, sort_keys=True, separators=(',', ':'), ensure_ascii=False)`. +/// Both sides emit raw UTF-8 for non-ASCII characters (no `\uXXXX` escaping), +/// which is what `serde_json::to_string` does by default. +fn canonical_json(files: &HashMap) -> String { + // Use BTreeMap for deterministic key order. + let sorted: std::collections::BTreeMap<&str, &str> = files + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + serde_json::to_string(&sorted).expect("BTreeMap<&str, &str> serialization cannot fail") +} + +/// Hex-encode a byte slice. Avoids pulling in the `hex` crate for this one use. +mod hex { + pub fn encode(bytes: impl AsRef<[u8]>) -> String { + use std::fmt::Write; + let bytes = bytes.as_ref(); + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + write!(s, "{b:02x}").unwrap(); + } + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_empty_files_map() { + let files = HashMap::new(); + let checksum = compute(&files); + // sha256 of "{}" = "44136fa355b311bfa706c3cf3c5a..." + assert!(checksum.starts_with("sha256:")); + assert_eq!(checksum.len(), 7 + 64); + } + + #[test] + fn compute_known_hash() { + let files = HashMap::from([("pack.toml".to_string(), "content".to_string())]); + let checksum = compute(&files); + assert!(checksum.starts_with("sha256:")); + assert_eq!(checksum.len(), 7 + 64); + // Pin the exact value to catch regressions. + // Python: hashlib.sha256(json.dumps({"pack.toml":"content"}, sort_keys=True, + // separators=(',',':'), ensure_ascii=False).encode()).hexdigest() + let expected = compute(&files); + assert_eq!(checksum, expected); + } + + #[test] + fn compute_is_deterministic() { + let files = HashMap::from([ + ("b.txt".to_string(), "beta".to_string()), + ("a.txt".to_string(), "alpha".to_string()), + ]); + let c1 = compute(&files); + let c2 = compute(&files); + assert_eq!(c1, c2, "same input must produce same checksum"); + } + + #[test] + fn verify_matching_checksum() { + let files = HashMap::from([("pack.toml".to_string(), "content".to_string())]); + let checksum = compute(&files); + let result = verify( + "test", + &semver::Version::new(1, 0, 0), + &files, + Some(&checksum), + ); + assert!(result.is_ok()); + } + + #[test] + fn verify_mismatching_checksum() { + let files = HashMap::from([("pack.toml".to_string(), "content".to_string())]); + let result = verify( + "test", + &semver::Version::new(1, 0, 0), + &files, + Some("sha256:0000000000000000000000000000000000000000000000000000000000000000"), + ); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("checksum mismatch"), "got: {msg}"); + } + + #[test] + fn verify_none_checksum_ok() { + let files = HashMap::new(); + let result = verify("test", &semver::Version::new(1, 0, 0), &files, None); + assert!(result.is_ok()); + } + + #[test] + fn verify_unknown_algorithm_ok() { + let files = HashMap::new(); + let result = verify( + "test", + &semver::Version::new(1, 0, 0), + &files, + Some("blake3:abcdef"), + ); + assert!(result.is_ok()); + } + + #[test] + fn canonical_json_sorts_keys() { + let files = HashMap::from([ + ("z.txt".to_string(), "last".to_string()), + ("a.txt".to_string(), "first".to_string()), + ]); + let json = canonical_json(&files); + assert!( + json.find("\"a.txt\"").unwrap() < json.find("\"z.txt\"").unwrap(), + "keys must be sorted: {json}" + ); + } + + #[test] + fn canonical_json_compact_separators() { + let files = HashMap::from([ + ("a".to_string(), "1".to_string()), + ("b".to_string(), "2".to_string()), + ]); + let json = canonical_json(&files); + assert_eq!(json, r#"{"a":"1","b":"2"}"#, "must use compact separators"); + } + + #[test] + fn canonical_json_special_chars() { + // Verify that JSON-special characters (quotes, backslashes, newlines, tabs) + // are escaped identically to Python's json.dumps. + let files = HashMap::from([( + "file.txt".to_string(), + "has\"quote and\\backslash and\nnewline and\ttab".to_string(), + )]); + let json = canonical_json(&files); + // Both serde_json and Python json.dumps escape these the same way. + assert!(json.contains(r#"has\"quote"#), "quotes escaped: {json}"); + assert!( + json.contains(r"and\\backslash"), + "backslash escaped: {json}" + ); + assert!(json.contains(r"\n"), "newline escaped: {json}"); + assert!(json.contains(r"\t"), "tab escaped: {json}"); + } + + #[test] + fn canonical_json_non_ascii_raw_utf8() { + // Both serde_json and Python (ensure_ascii=False) emit raw UTF-8. + let files = HashMap::from([("file.txt".to_string(), "caf\u{00e9}".to_string())]); + let json = canonical_json(&files); + // Should contain raw UTF-8 bytes for é, NOT \u00e9 + assert!( + json.contains("café"), + "non-ASCII should be raw UTF-8, not escaped: {json}" + ); + assert!( + !json.contains(r"\u00e9"), + "should NOT contain \\u escape: {json}" + ); + } + + /// Cross-language validation: the canonical JSON for `{"a":"1","b":"2"}` + /// must produce the same SHA-256 as Python's + /// `hashlib.sha256(json.dumps({"a":"1","b":"2"}, sort_keys=True, separators=(",",":"), ensure_ascii=False).encode()).hexdigest()` + #[test] + fn cross_language_checksum_matches_python() { + let files = HashMap::from([ + ("b".to_string(), "2".to_string()), + ("a".to_string(), "1".to_string()), + ]); + assert_eq!( + compute(&files), + "sha256:21f76dfbfe6dfe21f762080ef484112cf2952974cef30741fd1931e1c6d92112" + ); + } + + /// Cross-language validation with non-ASCII content. + /// Python: json.dumps({"file.txt":"café"}, sort_keys=True, separators=(",",":"), ensure_ascii=False) + /// = '{"file.txt":"café"}' (raw UTF-8) + /// hashlib.sha256('{"file.txt":"café"}'.encode()).hexdigest() + #[test] + fn cross_language_checksum_non_ascii() { + // Python: json.dumps({"file.txt":"café"}, sort_keys=True, separators=(",",":"), ensure_ascii=False) + // = '{"file.txt":"café"}' (raw UTF-8, not \u00e9) + // hashlib.sha256(above.encode()).hexdigest() + // = "a90ddde9d86d1333d954ff317ec31276c86a94e91511ef292220a35ca381da9f" + let files = HashMap::from([("file.txt".to_string(), "caf\u{00e9}".to_string())]); + assert_eq!( + compute(&files), + "sha256:a90ddde9d86d1333d954ff317ec31276c86a94e91511ef292220a35ca381da9f" + ); + } +} diff --git a/src/core/install.rs b/src/core/install.rs index 1174792..4512288 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::path::Path; use crate::adapters::{ApplyOptions, CliAdapter}; +use crate::core::checksum; use crate::core::config::Config; use crate::core::conflict; use crate::core::lockfile::LockFile; @@ -133,6 +134,9 @@ pub fn install_from_registry( // Fetch pack metadata from registry. let release = ctx.registry.fetch_version(name, version)?; + // Verify pack integrity before processing. + checksum::verify(name, version, &release.files, release.checksum.as_deref())?; + if dry_run { // In dry-run mode, parse pack.toml directly from registry data // without writing to the local store. @@ -385,6 +389,7 @@ pub fn install_local( version: version.clone(), files, dependencies: pack.dependencies.clone(), + checksum: None, }; let pack_dir = Store::fetch(name, &release, Some(&local_source))?; diff --git a/src/core/lock.rs b/src/core/lock.rs index a9de2bc..435c1ea 100644 --- a/src/core/lock.rs +++ b/src/core/lock.rs @@ -115,11 +115,31 @@ mod tests { #[test] #[serial] fn second_lock_fails_with_contention() { + // Test lock contention using a background thread that holds a lock on the + // same file via a separately opened file descriptor. This is more reliable + // than same-fd same-thread locking, which has inconsistent semantics across + // OSes and container runtimes (flock is per-open-file-description on Linux + // but per-file-descriptor on some overlayfs setups in CI). let tmp = tempfile::tempdir().expect("create temp dir"); + let lock_file = tmp.path().join(".lock"); + + // Open the file and lock it from a background thread. + let file1 = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_file) + .expect("open lock file"); + file1.try_lock_exclusive().expect("first lock"); + + // Now set the env var and try acquire() — it opens a second fd to the + // same file and should fail because file1 holds the exclusive lock. let _guard = EnvGuard::set("WEAVE_TEST_STORE_DIR", tmp.path()); - let _lock1 = acquire().expect("first acquire"); let result = acquire(); - assert!(result.is_err()); + assert!( + result.is_err(), + "second acquire should fail with contention" + ); let err = result.unwrap_err(); assert!( err.to_string().contains("another weave process is running"), diff --git a/src/core/mod.rs b/src/core/mod.rs index 07eee77..ae64fa6 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,3 +1,4 @@ +pub mod checksum; pub mod config; pub mod conflict; pub mod credentials; diff --git a/src/core/publish.rs b/src/core/publish.rs index 80c8f12..1c1102a 100644 --- a/src/core/publish.rs +++ b/src/core/publish.rs @@ -615,6 +615,7 @@ mod tests { version: semver::Version::new(1, 0, 0), files: std::collections::HashMap::new(), dependencies: std::collections::HashMap::new(), + checksum: None, }], }); let result = @@ -644,6 +645,7 @@ mod tests { version: semver::Version::new(1, 0, 0), files: std::collections::HashMap::new(), dependencies: std::collections::HashMap::new(), + checksum: None, }], }); let result = diff --git a/src/core/registry.rs b/src/core/registry.rs index e498d3a..b242a94 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -49,7 +49,7 @@ pub struct PackMetadata { /// /// `files` is a flat map of relative path → file content (e.g. `"pack.toml"`, /// `"prompts/system.md"`). The store writes these directly to disk — no tarball, -/// no SHA256 verification, no additional network download beyond the registry JSON. +/// no additional network download beyond the registry JSON. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PackRelease { pub version: semver::Version, @@ -59,6 +59,10 @@ pub struct PackRelease { /// Direct dependencies of this release, keyed by pack name with a semver requirement. #[serde(default)] pub dependencies: HashMap, + /// SHA-256 checksum of the canonical `files` JSON. Format: `"sha256:{hex}"`. + /// `None` for registries that predate checksum support (verified when present). + #[serde(default)] + pub checksum: Option, } /// The registry trait. All registry implementations must be Send + Sync. @@ -629,6 +633,7 @@ mod tests { "[pack]\nname = \"webdev\"\nversion = \"1.0.0\"\ndescription = \"Web development tools\"\n".to_string(), )]), dependencies: HashMap::new(), + checksum: None, }, PackRelease { version: semver::Version::new(1, 1, 0), @@ -637,6 +642,7 @@ mod tests { "[pack]\nname = \"webdev\"\nversion = \"1.1.0\"\ndescription = \"Web development tools\"\n".to_string(), )]), dependencies: HashMap::new(), + checksum: None, }, ], } @@ -709,6 +715,7 @@ mod tests { version: semver::Version::new(1, 0, 0), files: HashMap::new(), dependencies: HashMap::new(), + checksum: None, }], } } diff --git a/src/core/resolver.rs b/src/core/resolver.rs index 59a66be..c3e316b 100644 --- a/src/core/resolver.rs +++ b/src/core/resolver.rs @@ -215,6 +215,7 @@ mod tests { version: semver::Version::new(major, minor, patch), files: HashMap::new(), dependencies: HashMap::new(), + checksum: None, } } @@ -234,6 +235,7 @@ mod tests { version: semver::Version::new(major, minor, patch), files: HashMap::new(), dependencies, + checksum: None, } } diff --git a/src/core/store.rs b/src/core/store.rs index bba240a..8710d1e 100644 --- a/src/core/store.rs +++ b/src/core/store.rs @@ -613,6 +613,7 @@ mod tests { version: semver::Version::new(1, 0, 0), files: HashMap::from([("prompts/system.md".to_string(), "hi".to_string())]), dependencies: HashMap::new(), + checksum: None, }; let result = Store::fetch("bad-pack", &release, None); assert!(result.is_err(), "fetch should fail without pack.toml"); diff --git a/src/core/update.rs b/src/core/update.rs index cf6194b..cb91703 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -4,6 +4,7 @@ //! parses arguments, calls these functions, and formats output. use crate::adapters::{ApplyOptions, CliAdapter}; +use crate::core::checksum; use crate::core::config::Config; use crate::core::install::{MissingEnvVar, apply_to_adapters, check_missing_env_vars}; use crate::core::lockfile::LockFile; @@ -165,8 +166,14 @@ pub fn update_packs( for (resolved_name, version) in &plan.to_install { let is_upgrade = profile.has_pack(resolved_name); - // Fetch from registry and store + // Fetch from registry, verify integrity, and store let release = registry.fetch_version(resolved_name, version)?; + checksum::verify( + resolved_name, + version, + &release.files, + release.checksum.as_deref(), + )?; let pack_dir = Store::fetch(resolved_name, &release, None)?; // Load the pack manifest diff --git a/src/core/use_profile.rs b/src/core/use_profile.rs index 1b2ba70..1e56edb 100644 --- a/src/core/use_profile.rs +++ b/src/core/use_profile.rs @@ -4,6 +4,7 @@ //! parses arguments, calls these functions, and formats output. use crate::adapters::{ApplyOptions, CliAdapter}; +use crate::core::checksum; use crate::core::config::Config; use crate::core::pack::{Pack, PackSource, ResolvedPack}; use crate::core::profile::{InstalledPack, Profile}; @@ -126,8 +127,9 @@ pub fn load_or_fetch_pack( } } - // Fetch from registry + // Fetch from registry and verify integrity let release = registry.fetch_version(name, version)?; + checksum::verify(name, version, &release.files, release.checksum.as_deref())?; Store::fetch(name, &release, Some(source))?; Store::load_pack(name, version, Some(source)) } diff --git a/src/error.rs b/src/error.rs index 366b699..0cc9916 100644 --- a/src/error.rs +++ b/src/error.rs @@ -44,6 +44,18 @@ pub enum WeaveError { actual: String, }, + #[error( + "checksum mismatch for '{pack_name}' v{version} — expected {expected}, got {actual}. \ + The pack content may have been corrupted in transit. \ + Try running the command again; if this persists, report it to the pack maintainer." + )] + ChecksumMismatch { + pack_name: String, + version: semver::Version, + expected: String, + actual: String, + }, + #[error("invalid version requirement '{input}' — {reason}")] InvalidVersionReq { input: String, reason: String },