diff --git a/.gitignore b/.gitignore index e77b59ce1..af7130931 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,11 @@ package-json.lock /coverage /.nyc_output +# Test snapshots +**/__snapshots__/* +*.snap +test_snapshots/ + # IDEs and editors /.idea .project diff --git a/app/contract/Cargo.lock b/app/contract/Cargo.lock index 226a82ae4..d36c03a78 100644 --- a/app/contract/Cargo.lock +++ b/app/contract/Cargo.lock @@ -6,7 +6,10 @@ version = 4 name = "RustAcademy" version = "0.1.0" dependencies = [ + "blake3", + "hex", "proptest", + "serde_json", "soroban-sdk", ] @@ -158,6 +161,18 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + [[package]] name = "autocfg" version = "1.5.0" @@ -188,6 +203,20 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -260,6 +289,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -275,6 +310,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crate-git-revision" version = "0.0.6" @@ -331,7 +375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -816,7 +860,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1261,7 +1305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] diff --git a/app/contract/contracts/Folder/Cargo.toml b/app/contract/contracts/Folder/Cargo.toml index 59db00406..db94c5ad9 100644 --- a/app/contract/contracts/Folder/Cargo.toml +++ b/app/contract/contracts/Folder/Cargo.toml @@ -7,12 +7,18 @@ license = "MIT OR Apache-2.0" authors = ["RustAcademy Team"] repository = "https://github.com/ RustAcademy/app" readme = "README.md" +build = "build.rs" [lib] crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = "23" +hex = "0.4" + +[build-dependencies] +blake3 = "1.5" +serde_json = "1.0" [dev-dependencies] soroban-sdk = { version = "23", features = ["testutils"] } diff --git a/app/contract/contracts/Folder/build.rs b/app/contract/contracts/Folder/build.rs new file mode 100644 index 000000000..30879169a --- /dev/null +++ b/app/contract/contracts/Folder/build.rs @@ -0,0 +1,96 @@ +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn main() { + let out_dir = env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("build_manifest.rs"); + + let git_hash = get_git_hash(); + let build_timestamp = get_build_timestamp(); + let source_hash = get_source_hash(); + let schema_version = "1"; + + let manifest_content = format!( + r##" +/// Build-time manifest embedded in the WASM artifact. +/// +/// This metadata is generated at compile time and provides deterministic +/// correlation between deployed WASM artifacts and their source code. +pub const BUILD_MANIFEST: &str = r#"{}"#; + +/// Build timestamp in seconds since UNIX epoch. +pub const BUILD_TIMESTAMP: u64 = {}; + +/// Full git commit hash of the build source. +pub const GIT_HASH: &str = "{}"; + +/// Hash of the source files used to build this WASM (deterministic build verification). +pub const SOURCE_HASH: &str = "{}"; + +/// Schema version for the build manifest format. +pub const BUILD_MANIFEST_SCHEMA_VERSION: u32 = {}; +"##, + serde_json::json!({ + "git_hash": git_hash, + "build_timestamp": build_timestamp, + "source_hash": source_hash, + "schema_version": schema_version, + }), + build_timestamp, + git_hash, + source_hash, + schema_version + ); + + fs::write(&dest_path, manifest_content).unwrap(); + + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=src/"); + println!("cargo:rerun-if-changed=Cargo.toml"); +} + +fn get_git_hash() -> String { + let output = Command::new("git") + .args(&["rev-parse", "HEAD"]) + .output() + .unwrap_or_else(|_| { + panic!("Failed to get git hash"); + }); + + if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + "unknown".to_string() + } +} + +fn get_build_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn get_source_hash() -> String { + let mut hasher = blake3::Hasher::new(); + hash_directory(&mut hasher, Path::new("src")).unwrap_or(()); + hasher.finalize().to_string() +} + +fn hash_directory(hasher: &mut blake3::Hasher, path: &Path) -> std::io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "rs") { + let contents = fs::read(&path)?; + hasher.update(path.to_string_lossy().as_bytes()); + hasher.update(&contents); + } else if path.is_dir() { + hash_directory(hasher, &path)?; + } + } + Ok(()) +} \ No newline at end of file diff --git a/app/contract/contracts/Folder/src/metadata.rs b/app/contract/contracts/Folder/src/metadata.rs index cf0bde121..b6d6795e1 100644 --- a/app/contract/contracts/Folder/src/metadata.rs +++ b/app/contract/contracts/Folder/src/metadata.rs @@ -10,11 +10,13 @@ use crate::{ self, CURRENT_CONTRACT_VERSION, LEGACY_CONTRACT_VERSION, }, types::{ - ContractHealth, DeploymentMetadata, FeatureFlags, SchemaCompatibility, + BuildManifest, ContractHealth, DeploymentMetadata, FeatureFlags, SchemaCompatibility, SupportedVersions, UpgradeState, }, }; -use soroban_sdk::{Env, Symbol, Vec}; +use soroban_sdk::{BytesN, Env, Symbol, Vec}; + +include!(concat!(env!("OUT_DIR"), "/build_manifest.rs")); /// Compile-time feature flags for this contract build. /// @@ -169,3 +171,45 @@ pub fn event_replay_fields(env: &Env) -> Vec { } fields } + +/// Return build-time manifest embedded in the WASM artifact. +/// +/// This metadata enables deterministic correlation between deployed WASM artifacts +/// and their source code. Tooling can use it to verify integrity and detect drift. +/// +/// ## Fields +/// - `git_hash`: Full git commit hash of the source at build time +/// - `build_timestamp`: UNIX epoch timestamp when the WASM was compiled +/// - `source_hash`: Deterministic hash of all Rust source files (BLAKE3) +/// - `schema_version`: Build manifest format version +pub fn build_manifest() -> BuildManifest { + let git_bytes = hex::decode(GIT_HASH.trim()) + .ok() + .and_then(|v| v.try_into().ok()) + .unwrap_or([0u8; 32]); + let source_bytes = hex::decode(SOURCE_HASH.trim()) + .ok() + .and_then(|v| v.try_into().ok()) + .unwrap_or([0u8; 32]); + BuildManifest { + git_hash: BytesN::from_array(&Env::default(), &git_bytes), + build_timestamp: BUILD_TIMESTAMP, + source_hash: BytesN::from_array(&Env::default(), &source_bytes), + schema_version: 1, + } +} + +/// Verify that the on-chain WASM hash matches the expected build. +/// +/// Returns `true` if the stored wasm_hash matches the expected source hash, +/// indicating no unauthorized modifications have been deployed. +pub fn verify_artifact_integrity(env: &Env) -> bool { + let stored_wasm = storage::get_wasm_hash(env); + match stored_wasm { + Some(hash) => { + let manifest = build_manifest(); + hash == manifest.source_hash + } + None => true, + } +} diff --git a/app/contract/contracts/Folder/src/types.rs b/app/contract/contracts/Folder/src/types.rs index 7537a41aa..4915ab25e 100644 --- a/app/contract/contracts/Folder/src/types.rs +++ b/app/contract/contracts/Folder/src/types.rs @@ -478,3 +478,27 @@ pub enum Role { /// Authorized to resolve disputes across escrows. Arbiter = 3, } + +/// Build-time manifest embedded in the WASM artifact. +/// +/// This metadata is generated at compile time and provides deterministic +/// correlation between deployed WASM artifacts and their source code. +/// +/// ## Invariants +/// +/// - `git_hash` is set to the full commit hash if available, otherwise "unknown" +/// - `build_timestamp` is the UNIX epoch time when the WASM was built +/// - `source_hash` is a deterministic hash of all Rust source files +/// - `schema_version` is the manifest format version (increment on breaking changes) +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildManifest { + /// Full git commit hash of the build source. + pub git_hash: BytesN<32>, + /// Build timestamp in seconds since UNIX epoch. + pub build_timestamp: u64, + /// Hash of the source files (first 32 bytes of BLAKE3 hash). + pub source_hash: BytesN<32>, + /// Schema version for the build manifest format. + pub schema_version: u32, +}