///|
/// Represents a snapshot of a file or software dependency in the supply chain.
pub(all) struct FileSnapshot {
path : String
size : Int64
hash_xx64 : UInt64
hash_sha256 : String
}
///|
/// A Software Supply Chain Manifest (SBOM / Snapshot) with embedded Merkle root.
pub(all) struct Manifest {
name : String
version : String
created_at : String
files : Array[FileSnapshot]
mut merkle_root : String
}
///|
/// Represents differences discovered between two supply chain manifests.
pub(all) struct ManifestDiff {
added : Array[FileSnapshot]
removed : Array[FileSnapshot]
modified : Array[FileSnapshot]
}
///|
/// Construct a FileSnapshot by inspecting raw file contents.
pub fn FileSnapshot::from_bytes(path : String, data : Bytes) -> FileSnapshot {
let size = data.length().to_int64()
let hash_xx64 = @digest.xxhash64(data)
let hash_sha256 = @digest.sha256_hex(data)
{ path, size, hash_xx64, hash_sha256 }
}
///|
/// Serialize a FileSnapshot to a canonical byte representation for Merkle tree hashing.
pub fn FileSnapshot::canonical_bytes(self : FileSnapshot) -> Bytes {
let s = self.path +
":" +
self.size.to_string() +
":" +
self.hash_xx64.to_string() +
":" +
self.hash_sha256
let buf : Array[Byte] = []
for i = 0; i < s.length(); i = i + 1 {
buf.push(s[i].to_int().to_byte())
}
Bytes::from_array(buf)
}
///|
/// Construct a new supply chain Manifest and automatically compute its Merkle root.
pub fn Manifest::new(
name : String,
version : String,
created_at : String,
files : Array[FileSnapshot],
) -> Manifest {
let manifest = { name, version, created_at, files, merkle_root: "" }
manifest.refresh_merkle_root()
manifest
}
///|
/// Compute the Merkle tree for all files in this manifest.
pub fn Manifest::compute_merkle_tree(self : Manifest) -> @merkle.MerkleTree {
let leaves : Array[Bytes] = []
for i = 0; i < self.files.length(); i = i + 1 {
leaves.push(self.files[i].canonical_bytes())
}
@merkle.MerkleTree::new(leaves)
}
///|
/// Recalculate and update the merkle_root property of the manifest.
pub fn Manifest::refresh_merkle_root(self : Manifest) -> Unit {
let tree = self.compute_merkle_tree()
self.merkle_root = tree.root_hex()
}
///|
/// Verify whether the files currently in the manifest match the stored Merkle root.
pub fn Manifest::verify_integrity(self : Manifest) -> Bool {
let tree = self.compute_merkle_tree()
tree.root_hex() == self.merkle_root
}
///|
/// Serialize the Manifest into a human and machine readable JSON string.
pub fn Manifest::to_json_string(self : Manifest) -> String {
let buf = StringBuilder::new()
buf.write_string("{\n")
buf.write_string(" \"name\": \"" + escape_json(self.name) + "\",\n")
buf.write_string(" \"version\": \"" + escape_json(self.version) + "\",\n")
buf.write_string(
" \"created_at\": \"" + escape_json(self.created_at) + "\",\n",
)
buf.write_string(
" \"merkle_root\": \"" + escape_json(self.merkle_root) + "\",\n",
)
buf.write_string(" \"files\": [\n")
for i = 0; i < self.files.length(); i = i + 1 {
let f = self.files[i]
buf.write_string(" {\n")
buf.write_string(" \"path\": \"" + escape_json(f.path) + "\",\n")
buf.write_string(" \"size\": " + f.size.to_string() + ",\n")
buf.write_string(" \"hash_xx64\": " + f.hash_xx64.to_string() + ",\n")
buf.write_string(
" \"hash_sha256\": \"" + escape_json(f.hash_sha256) + "\"\n",
)
if i + 1 < self.files.length() {
buf.write_string(" },\n")
} else {
buf.write_string(" }\n")
}
}
buf.write_string(" ]\n")
buf.write_string("}")
buf.to_string()
}
///|
fn escape_json(s : String) -> String {
let buf = StringBuilder::new()
for i = 0; i < s.length(); i = i + 1 {
let ci = s[i].to_int()
if ci == '"'.to_int() {
buf.write_string("\\\"")
} else if ci == '\\'.to_int() {
buf.write_string("\\\\")
} else {
buf.write_char(ci.unsafe_to_char())
}
}
buf.to_string()
}
///|
/// Compare two manifests (`self` as old/baseline, `other` as new) to find added, removed, and modified files.
pub fn Manifest::diff(self : Manifest, other : Manifest) -> ManifestDiff {
let added : Array[FileSnapshot] = []
let removed : Array[FileSnapshot] = []
let modified : Array[FileSnapshot] = []
for i = 0; i < self.files.length(); i = i + 1 {
let f_old = self.files[i]
match find_file_by_path(other.files, f_old.path) {
Some(f_new) =>
if f_old.hash_sha256 != f_new.hash_sha256 || f_old.size != f_new.size {
modified.push(f_new)
}
None => removed.push(f_old)
}
}
for i = 0; i < other.files.length(); i = i + 1 {
let f_new = other.files[i]
match find_file_by_path(self.files, f_new.path) {
Some(_) => ()
None => added.push(f_new)
}
}
{ added, removed, modified }
}
///|
fn find_file_by_path(
files : Array[FileSnapshot],
path : String,
) -> FileSnapshot? {
for i = 0; i < files.length(); i = i + 1 {
if files[i].path == path {
return Some(files[i])
}
}
None
}