Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 32 additions & 27 deletions src/mxpak.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
79 changes: 79 additions & 0 deletions src/mxpak/audit.gleam
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
}
}
}
142 changes: 142 additions & 0 deletions test/audit_test.gleam
Original file line number Diff line number Diff line change
@@ -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
}
Loading