From 76f4098714b35fefca49522184dbb79b31f310c9 Mon Sep 17 00:00:00 2001 From: GGOBP Date: Thu, 3 Sep 2026 10:41:29 +0900 Subject: [PATCH] Store complete packages atomically in the mxpak cache Fixes glendix-labs/mendraw#12 --- src/mxpak/cache.gleam | 5 +++ src/mxpak/cache/store.gleam | 79 ++++++++++++++++++++++++++++++++++--- src/mxpak/downloader.gleam | 4 +- test/cache_test.gleam | 58 +++++++++++++++++++++++++++ test/mpk_test.gleam | 4 +- 5 files changed, 140 insertions(+), 10 deletions(-) diff --git a/src/mxpak/cache.gleam b/src/mxpak/cache.gleam index 3ea3275..56205c2 100644 --- a/src/mxpak/cache.gleam +++ b/src/mxpak/cache.gleam @@ -23,6 +23,11 @@ pub fn has(hash hash: String) -> Result(Bool, error.Error) { store.has(hash) } +/// Reports whether a complete package cache entry exists. +pub fn has_package(hash hash: String) -> Result(Bool, error.Error) { + store.has_package(hash) +} + /// Stores an MPK and its extracted entries in the content-addressable cache. pub fn put( data data: BitArray, diff --git a/src/mxpak/cache/store.gleam b/src/mxpak/cache/store.gleam index 8554704..7779393 100644 --- a/src/mxpak/cache/store.gleam +++ b/src/mxpak/cache/store.gleam @@ -2,6 +2,7 @@ //// // CAS (Content-Addressable Storage) — ~/.mxpak/store/{sha256}/ +import gleam/int import gleam/list import gleam/result import gleam/string @@ -31,6 +32,31 @@ pub fn has(hash hash: String) -> Result(Bool, error.Error) { }) } +/// Reports whether a complete package cache entry exists. +/// +/// A package entry is complete only when its original MPK and the manifest +/// written after every entry are present. +pub fn has_package(hash hash: String) -> Result(Bool, error.Error) { + let dir = cache_path(hash) + use directory_exists <- result.try( + simplifile.is_directory(dir) + |> result.map_error(fn(reason) { + error.cache("캐시 경로 확인 실패: " <> dir <> ": " <> string.inspect(reason)) + }), + ) + case directory_exists { + False -> Ok(False) + True -> + simplifile.is_file(dir <> "/" <> manifest_filename) + |> result.map_error(fn(reason) { + error.cache("캐시 매니페스트 확인 실패: " <> dir <> ": " <> string.inspect(reason)) + }) + |> result.map(fn(manifest_exists) { + manifest_exists && is_original_present(dir) + }) + } +} + /// Stores an MPK and its entries under their content hash. pub fn put( data data: BitArray, @@ -38,22 +64,23 @@ pub fn put( ) -> Result(#(String, String), error.Error) { let hash = integrity.sha256(data) let dir = cache_path(hash) - use cached <- result.try(has(hash)) + use cached <- result.try(has_package(hash)) case cached { True -> Ok(#(dir, hash)) False -> { + let temp_dir = dir <> ".tmp-" <> int.to_string(unique_integer()) use _ <- result.try( - simplifile.create_directory_all(dir) - |> result.map_error(fn(_) { error.cache("캐시 디렉토리 생성 실패: " <> dir) }), + simplifile.create_directory_all(temp_dir) + |> result.map_error(fn(_) { error.cache("캐시 디렉토리 생성 실패: " <> temp_dir) }), ) use _ <- result.try( - simplifile.write_bits(dir <> "/original.mpk", data) + simplifile.write_bits(temp_dir <> "/original.mpk", data) |> result.map_error(fn(_) { error.cache("원본 .mpk 캐시 저장 실패") }), ) use _ <- result.try( list.try_each(entries, fn(entry) { let #(name, content) = entry - let out_path = dir <> "/" <> name + let out_path = temp_dir <> "/" <> name let out_dir = dirname(out_path) use _ <- result.try( simplifile.create_directory_all(out_dir) @@ -63,6 +90,22 @@ pub fn put( |> result.map_error(fn(_) { error.cache("캐시 파일 쓰기 실패: " <> out_path) }) }), ) + use _ <- result.try( + simplifile.write( + temp_dir <> "/" <> manifest_filename, + entries + |> list.map(fn(entry) { entry.0 }) + |> string.join("\n"), + ) + |> result.map_error(fn(_) { + error.cache("캐시 매니페스트 저장 실패: " <> temp_dir) + }), + ) + use _ <- result.try(remove_incomplete_directory(dir)) + use _ <- result.try( + simplifile.rename(temp_dir, dir) + |> result.map_error(fn(_) { error.cache("캐시 디렉토리 확정 실패: " <> dir) }), + ) Ok(#(dir, hash)) } } @@ -168,6 +211,8 @@ pub fn create_hard_link( make_hard_link(existing_path, new_path) } +const manifest_filename = "manifest.mxpak" + fn dirname(path: String) -> String { let parts = string.split(path, "/") case list.length(parts) > 1 { @@ -199,7 +244,7 @@ fn link_dir(src: String, dest: String) -> Result(Nil, error.Error) { |> result.map_error(fn(_) { error.cache("디렉토리 읽기 실패: " <> src) }), ) list.try_each(files, fn(file) { - case file == "original.mpk" { + case file == "original.mpk" || file == manifest_filename { True -> Ok(Nil) False -> link_entry(src <> "/" <> file, dest <> "/" <> file) } @@ -223,6 +268,23 @@ fn link_entry(src_path: String, dest_path: String) -> Result(Nil, error.Error) { } } +fn remove_incomplete_directory(dir: String) -> Result(Nil, error.Error) { + case simplifile.is_directory(dir) { + Ok(True) -> + simplifile.delete(dir) + |> result.map_error(fn(_) { error.cache("불완전한 캐시 디렉토리 삭제 실패: " <> dir) }) + Ok(False) -> Ok(Nil) + Error(_) -> Ok(Nil) + } +} + +fn is_original_present(dir: String) -> Bool { + case simplifile.is_file(dir <> "/original.mpk") { + Ok(found) -> found + Error(_) -> False + } +} + // -- FFI -- @external(erlang, "mxpak_ffi", "make_hard_link") @@ -233,3 +295,8 @@ fn make_hard_link( @external(erlang, "mxpak_ffi", "get_home_dir") fn get_home_dir() -> Result(String, error.MissingValue) + +// -- FFI -- + +@external(erlang, "erlang", "unique_integer") +fn unique_integer() -> Int diff --git a/src/mxpak/downloader.gleam b/src/mxpak/downloader.gleam index 2f16392..26a6de9 100644 --- a/src/mxpak/downloader.gleam +++ b/src/mxpak/downloader.gleam @@ -43,7 +43,7 @@ pub fn download_and_extract( let cache_dir = project_root <> "/build/widgets/" <> name use zip_data <- result.try(download_binary(url)) let hash = integrity.sha256(zip_data) - use cached <- result.try(cache.has(hash)) + use cached <- result.try(cache.has_package(hash)) case cached { True -> { io.println_error(name <> " v" <> version <> " — 캐시 히트") @@ -96,7 +96,7 @@ pub fn download_mpk( use _ <- result.try(widget.validate_name(name)) use zip_data <- result.try(download_binary(url)) let hash = integrity.sha256(zip_data) - use cached <- result.try(cache.has(hash)) + use cached <- result.try(cache.has_package(hash)) case cached { True -> { io.println_error(name <> " v" <> version <> " — 캐시 히트") diff --git a/test/cache_test.gleam b/test/cache_test.gleam index 65f34cf..a93e832 100644 --- a/test/cache_test.gleam +++ b/test/cache_test.gleam @@ -83,6 +83,64 @@ pub fn store_has_missing_test() -> Nil { Nil } +/// Verifies package stores write a manifest and restore only payload entries. +pub fn store_put_completes_packages_test() -> Nil { + let data = <<"package bytes":utf8>> + let entries = [ + #("package.xml", <<"":utf8>>), + #("com/Widget.mjs", <<"export default 1":utf8>>), + ] + let stored = + store.put(data, entries) + |> should.be_ok + let hash = stored.1 + store.has_package(hash) + |> should.be_ok + |> should.be_true + let target = "build/test_tmp/restore_complete" + store.link_to_project(hash, target) + |> should.be_ok + simplifile.is_file(target <> "/manifest.mxpak") + |> should.be_ok + |> should.be_false + simplifile.is_file(target <> "/com/Widget.mjs") + |> should.be_ok + |> should.be_true + simplifile.delete(target) + |> should.be_ok + simplifile.delete(store.cache_path(hash)) + |> should.be_ok + Nil +} + +/// Verifies incomplete package directories are rebuilt instead of trusted. +pub fn store_put_rebuilds_incomplete_packages_test() -> Nil { + let data = <<"incomplete package":utf8>> + let hash = integrity.sha256(data) + let dir = store.cache_path(hash) + simplifile.create_directory_all(dir) + |> should.be_ok + simplifile.write_bits(dir <> "/partial.txt", <<"partial":utf8>>) + |> should.be_ok + store.has_package(hash) + |> should.be_ok + |> should.be_false + store.put(data, [#("complete.txt", <<"complete":utf8>>)]) + |> should.be_ok + store.has_package(hash) + |> should.be_ok + |> should.be_true + simplifile.is_file(dir <> "/partial.txt") + |> should.be_ok + |> should.be_false + simplifile.is_file(dir <> "/complete.txt") + |> should.be_ok + |> should.be_true + simplifile.delete(dir) + |> should.be_ok + Nil +} + /// Verifies cache roundtrip behavior. pub fn cache_roundtrip_test() -> Nil { let data = <<"roundtrip test":utf8>> diff --git a/test/mpk_test.gleam b/test/mpk_test.gleam index 945cf8c..6216b32 100644 --- a/test/mpk_test.gleam +++ b/test/mpk_test.gleam @@ -79,7 +79,7 @@ pub fn zip_extract_corrupted_directory_offset_test() -> Nil { Nil } -// A stored ZIP archive containing one ../evil.txt entry. +/// Verifies parse widget name behavior. pub fn parse_widget_name_test() -> Nil { let xml = "DataGrid" xml.parse_widget_name(xml) @@ -242,6 +242,7 @@ pub fn metadata_classic_roundtrip_test() -> Nil { Nil } +// A stored ZIP archive containing one ../evil.txt entry. fn parent_directory_zip_binary() -> BitArray { << 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf4, 0x4c, 0x23, @@ -305,7 +306,6 @@ fn truncated_zip_binary() -> BitArray { >> } -/// Verifies parse widget name behavior. // A ZIP archive whose central-directory offset points beyond the buffer. fn corrupted_offset_zip_binary() -> BitArray {