From e521077248a7a931f91033facff5a0fb8ebfd0bc Mon Sep 17 00:00:00 2001 From: GGOBP Date: Thu, 3 Sep 2026 09:49:32 +0900 Subject: [PATCH] Recompute cached MPK hashes during audit Fixes glendix-labs/mendraw#4 --- src/mxpak.gleam | 59 ++++++++++-------- src/mxpak/audit.gleam | 79 +++++++++++++++++++++++ test/audit_test.gleam | 142 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 27 deletions(-) create mode 100644 src/mxpak/audit.gleam create mode 100644 test/audit_test.gleam diff --git a/src/mxpak.gleam b/src/mxpak.gleam index 92bc333..c3a74d7 100644 --- a/src/mxpak.gleam +++ b/src/mxpak.gleam @@ -7,6 +7,7 @@ import gleam/json import gleam/list import gleam/option import gleam/string +import mxpak/audit import mxpak/cache import mxpak/cli import mxpak/config @@ -209,42 +210,46 @@ fn run_info(name: String) -> Nil { // -- Audit -- fn run_audit(project_root: String) -> Nil { - case lockfile.read(project_root) { + case audit.verify(project_root) { Ok(lock) -> { - let entries = dict.to_list(lock.entries) + let entries = lock case entries { [] -> io.println("락파일에 엔트리 없음") - _ -> - list.each(entries, fn(pair) { - let #(name, entry) = pair - case entry.hash { - "" -> io.println(" " <> name <> " — 해시 없음 (검증 불가)") - hash -> - case cache.has(hash) { - Ok(True) -> - io.println(" " <> name <> " v" <> entry.version <> " ✓") - Ok(False) -> - io.println( - " " <> name <> " v" <> entry.version <> " ✗ 캐시 없음", - ) - Error(reason) -> - io.println_error( - " " - <> name - <> " v" - <> entry.version - <> " — 캐시 확인 실패: " - <> error.message(reason), - ) - } - } - }) + _ -> list.each(entries, fn(pair) { print_audit(pair) }) } } Error(_) -> io.println("mxpak.lock 파일이 없습니다. mxp install을 먼저 실행하세요.") } } +fn print_audit(entry: audit.EntryAudit) -> Nil { + let name = entry.name + case entry.status { + audit.Verified(version) -> + io.println(" " <> name <> " v" <> version <> " ✓") + audit.HashMismatch(version, expected, actual) -> + io.println( + " " + <> name + <> " v" + <> version + <> " ✗ 해시 불일치 (기대 " + <> string.slice(expected, 0, 12) + <> ", 실제 " + <> string.slice(actual, 0, 12) + <> ")", + ) + audit.CacheMissing(version) -> + io.println(" " <> name <> " v" <> version <> " ✗ 캐시 없음") + audit.CacheUnreadable(version, reason) -> + io.println_error( + " " <> name <> " v" <> version <> " — 캐시 읽기 실패: " <> reason, + ) + audit.HashUnavailable(version) -> + io.println(" " <> name <> " v" <> version <> " — 해시 없음 (검증 불가)") + } +} + // -- Cache Clean -- fn run_cache_clean() -> Nil { case cache.clean() { diff --git a/src/mxpak/audit.gleam b/src/mxpak/audit.gleam new file mode 100644 index 0000000..891ba5a --- /dev/null +++ b/src/mxpak/audit.gleam @@ -0,0 +1,79 @@ +//// Verifies cached package contents against lockfile hashes. +//// + +import gleam/dict +import gleam/list +import gleam/result +import gleam/string +import mxpak/cache/integrity +import mxpak/cache/store +import mxpak/error +import mxpak/lockfile +import simplifile + +/// The verification outcome for one locked widget. +pub type AuditStatus { + /// The cached MPK content matches the pinned hash. + Verified(version: String) + /// The cached MPK content differs from the pinned hash. + HashMismatch(version: String, expected: String, actual: String) + /// No cache entry exists for the pinned hash. + CacheMissing(version: String) + /// The cache entry exists but its MPK could not be read. + CacheUnreadable(version: String, reason: String) + /// The lock entry has no pinned hash to verify. + HashUnavailable(version: String) +} + +/// One widget audit result. +pub type EntryAudit { + EntryAudit(name: String, status: AuditStatus) +} + +/// Audits every lock entry against the global content-addressable cache. +pub fn verify( + project_root project_root: String, +) -> Result(List(EntryAudit), error.Error) { + use lock <- result.try(lockfile.read(project_root)) + Ok(verify_entries(lock.entries, store.cache_root())) +} + +/// Audits lock entries against the supplied cache root. +pub fn verify_entries( + entries entries: dict.Dict(String, lockfile.LockEntry), + cache_root cache_root: String, +) -> List(EntryAudit) { + entries + |> dict.to_list + |> list.sort(fn(left, right) { string.compare(left.0, right.0) }) + |> list.map(fn(pair) { + let #(name, entry) = pair + EntryAudit(name: name, status: audit_entry(entry, cache_root)) + }) +} + +fn audit_entry( + entry entry: lockfile.LockEntry, + cache_root cache_root: String, +) -> AuditStatus { + case entry.hash { + "" -> HashUnavailable(entry.version) + hash -> { + let mpk_path = cache_root <> "/" <> hash <> "/original.mpk" + case simplifile.read_bits(mpk_path) { + Error(simplifile.Enoent) -> CacheMissing(entry.version) + Error(reason) -> CacheUnreadable(entry.version, string.inspect(reason)) + Ok(data) -> + case integrity.sha256(data) == hash { + True -> Verified(entry.version) + False -> + HashMismatch( + version: entry.version, + expected: hash, + actual: integrity.sha256(data), + ) + } + } + } + } +} diff --git a/test/audit_test.gleam b/test/audit_test.gleam new file mode 100644 index 0000000..b99cb6b --- /dev/null +++ b/test/audit_test.gleam @@ -0,0 +1,142 @@ +//// Tests audit behavior for mxpak. +//// + +import gleam/dict +import gleam/list +import gleam/option +import gleeunit +import gleeunit/should +import mxpak/audit +import mxpak/cache/integrity +import mxpak/lockfile +import mxpak/widget +import simplifile + +/// Runs this module's test suite. +pub fn main() -> Nil { + gleeunit.main() +} + +/// Verifies matching cache content reports as verified. +pub fn audit_verified_content_test() -> Nil { + let root = "build/test_tmp/audit_ok" + let hash = integrity.sha256(<<"package bytes":utf8>>) + write_cache_entry(root, hash, <<"package bytes":utf8>>) + let entries = + dict.from_list([ + #( + "DataGrid", + lockfile.LockEntry( + "1.0.0", + hash, + option.None, + option.None, + widget.Classic, + ), + ), + ]) + audit.verify_entries(entries, root) + |> list.first + |> should.equal( + Ok(audit.EntryAudit( + name: "DataGrid", + status: audit.Verified(version: "1.0.0"), + )), + ) + simplifile.delete(root) + |> should.be_ok + Nil +} + +/// Verifies tampered cache content reports a hash mismatch. +pub fn audit_tampered_content_test() -> Nil { + let root = "build/test_tmp/audit_tampered" + let expected = integrity.sha256(<<"expected bytes":utf8>>) + write_cache_entry(root, expected, <<"tampered bytes":utf8>>) + let entries = + dict.from_list([ + #( + "DataGrid", + lockfile.LockEntry( + "1.0.0", + expected, + option.None, + option.None, + widget.Classic, + ), + ), + ]) + audit.verify_entries(entries, root) + |> list.first + |> should.equal( + Ok(audit.EntryAudit( + name: "DataGrid", + status: audit.HashMismatch( + version: "1.0.0", + expected: expected, + actual: integrity.sha256(<<"tampered bytes":utf8>>), + ), + )), + ) + simplifile.delete(root) + |> should.be_ok + Nil +} + +/// Verifies missing cache content and missing hashes report distinctly. +pub fn audit_missing_states_test() -> Nil { + let root = "build/test_tmp/audit_missing" + let hash = integrity.sha256(<<"unused":utf8>>) + let entries = + dict.from_list([ + #( + "Missing", + lockfile.LockEntry( + "1.0.0", + hash, + option.None, + option.None, + widget.Classic, + ), + ), + #( + "NoHash", + lockfile.LockEntry( + "1.0.0", + "", + option.None, + option.None, + widget.Classic, + ), + ), + ]) + let results = audit.verify_entries(entries, root) + { list.length(results) == 2 } + |> should.be_true + results + |> list.find(fn(entry) { entry.name == "Missing" }) + |> should.equal( + Ok(audit.EntryAudit( + name: "Missing", + status: audit.CacheMissing(version: "1.0.0"), + )), + ) + results + |> list.find(fn(entry) { entry.name == "NoHash" }) + |> should.equal( + Ok(audit.EntryAudit( + name: "NoHash", + status: audit.HashUnavailable(version: "1.0.0"), + )), + ) + Nil +} + +fn write_cache_entry(root: String, hash: String, content: BitArray) -> Nil { + let dir = root <> "/" <> hash + simplifile.create_directory_all(dir) + |> should.be_ok + simplifile.write_bits(dir <> "/original.mpk", content) + |> should.be_ok + Nil +}