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
5 changes: 5 additions & 0 deletions src/mxpak/cache.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 73 additions & 6 deletions src/mxpak/cache/store.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
////

// CAS (Content-Addressable Storage) — ~/.mxpak/store/{sha256}/
import gleam/int
import gleam/list
import gleam/result
import gleam/string
Expand Down Expand Up @@ -31,29 +32,55 @@ 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,
entries entries: List(#(String, BitArray)),
) -> 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)
Expand All @@ -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))
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
Expand All @@ -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
4 changes: 2 additions & 2 deletions src/mxpak/downloader.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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 <> " — 캐시 히트")
Expand Down Expand Up @@ -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 <> " — 캐시 히트")
Expand Down
58 changes: 58 additions & 0 deletions test/cache_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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", <<"<package/>":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>>
Expand Down
4 changes: 2 additions & 2 deletions test/mpk_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<widget><name>DataGrid</name></widget>"
xml.parse_widget_name(xml)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading