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 {