From 9fe0da49fda98540ee51b540c3212b1535336da3 Mon Sep 17 00:00:00 2001 From: lahiruudayakumara <79270918+lahiruudayakumara@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:14:02 +0530 Subject: [PATCH 1/7] feat(workspace): add CAS crates and archive dependencies --- Cargo.lock | 287 +++++++++++++++++++++++++++++++++++ Cargo.toml | 7 + crates/corex-cli/Cargo.toml | 1 + crates/corex-core/Cargo.toml | 3 + 4 files changed, 298 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index cdddd67..2138a0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,43 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "corex-cache" +version = "0.1.0" +dependencies = [ + "corex-config", + "corex-errors", + "serde", + "serde_json", +] + [[package]] name = "corex-cli" version = "0.1.0" @@ -13,6 +50,7 @@ dependencies = [ "corex-registry", "corex-resolver", "corex-semver", + "corex-store", "serde", "serde_json", ] @@ -30,13 +68,16 @@ dependencies = [ name = "corex-core" version = "0.1.0" dependencies = [ + "corex-cache", "corex-config", "corex-errors", + "corex-fetch", "corex-graph", "corex-manifest", "corex-registry", "corex-resolver", "corex-semver", + "corex-store", "serde", ] @@ -47,6 +88,19 @@ dependencies = [ "serde", ] +[[package]] +name = "corex-fetch" +version = "0.1.0" +dependencies = [ + "corex-cache", + "corex-errors", + "corex-registry", + "corex-store", + "flate2", + "sha2", + "tar", +] + [[package]] name = "corex-graph" version = "0.1.0" @@ -99,12 +153,120 @@ dependencies = [ "serde", ] +[[package]] +name = "corex-store" +version = "0.1.0" +dependencies = [ + "corex-errors", + "flate2", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -133,12 +295,40 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a86e374f16219dbd32d4044d3b27200c2064043e40f0e6d4aa7fa7b047c5958" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -157,6 +347,25 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "serde" version = "1.0.229" @@ -209,6 +418,23 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "syn" version = "3.0.3" @@ -220,6 +446,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "toml" version = "0.8.23" @@ -261,12 +511,39 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" @@ -276,6 +553,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 3efcc88..a61e919 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ members = [ "crates/corex-graph", "crates/corex-registry", "crates/corex-resolver", + "crates/corex-fetch", + "crates/corex-cache", + "crates/corex-store", ] resolver = "2" @@ -34,5 +37,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" js-semver = "0.3" +tar = "0.4" +flate2 = "1.0" +sha2 = "0.10" +tempfile = "3.10" diff --git a/crates/corex-cli/Cargo.toml b/crates/corex-cli/Cargo.toml index e0683d2..af8bc04 100644 --- a/crates/corex-cli/Cargo.toml +++ b/crates/corex-cli/Cargo.toml @@ -19,6 +19,7 @@ corex-registry = { path = "../corex-registry" } corex-semver = { path = "../corex-semver" } corex-graph = { path = "../corex-graph" } corex-resolver = { path = "../corex-resolver" } +corex-store = { path = "../corex-store" } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/corex-core/Cargo.toml b/crates/corex-core/Cargo.toml index efe5c03..1fa75fe 100644 --- a/crates/corex-core/Cargo.toml +++ b/crates/corex-core/Cargo.toml @@ -15,6 +15,9 @@ corex-semver = { path = "../corex-semver" } corex-graph = { path = "../corex-graph" } corex-registry = { path = "../corex-registry" } corex-resolver = { path = "../corex-resolver" } +corex-store = { path = "../corex-store" } +corex-cache = { path = "../corex-cache" } +corex-fetch = { path = "../corex-fetch" } serde = { workspace = true } From ee8aefad6d3538fd552571301f2f906f37d15cf3 Mon Sep 17 00:00:00 2001 From: lahiruudayakumara <79270918+lahiruudayakumara@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:14:22 +0530 Subject: [PATCH 2/7] feat(store): implement global content-addressed store and safe extraction --- crates/corex-store/Cargo.toml | 20 + crates/corex-store/src/lib.rs | 677 ++++++++++++++++++++++++++++++++++ 2 files changed, 697 insertions(+) create mode 100644 crates/corex-store/Cargo.toml create mode 100644 crates/corex-store/src/lib.rs diff --git a/crates/corex-store/Cargo.toml b/crates/corex-store/Cargo.toml new file mode 100644 index 0000000..a0af842 --- /dev/null +++ b/crates/corex-store/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "corex-store" +description = "Immutable content-addressed store (CAS) for CorexPM" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +corex-errors = { path = "../corex-errors" } +serde = { workspace = true } +serde_json = { workspace = true } +tar = { workspace = true } +flate2 = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/corex-store/src/lib.rs b/crates/corex-store/src/lib.rs new file mode 100644 index 0000000..a9a8d9c --- /dev/null +++ b/crates/corex-store/src/lib.rs @@ -0,0 +1,677 @@ +//! Content-addressed storage (CAS) and safe archive extraction for `CorexPM`. + +use corex_errors::{Diagnostic, ErrorFamily}; + +use std::path::{Path, PathBuf}; + +/// Statistics report for the Content-Addressed Store. +#[derive(Clone, Debug, serde::Serialize)] +pub struct StoreStats { + /// Number of unique packages in the store. + pub unique_packages: usize, + /// Physical bytes occupied in the store. + pub physical_bytes: u64, + /// Logical bytes referenced by local projects. + pub logical_bytes: u64, + /// Ratio of logical bytes to physical bytes. + pub reuse_ratio: f64, +} + +/// Content-addressed package store manager. +#[derive(Debug)] +#[allow(clippy::struct_field_names)] +pub struct ContentAddressedStore { + _root_dir: PathBuf, + temp_dir: PathBuf, + packages_dir: PathBuf, +} + +impl ContentAddressedStore { + /// Creates a new `ContentAddressedStore` under the specified root path. + #[must_use] + pub fn new(root_dir: impl Into) -> Self { + let root = root_dir.into(); + Self { + temp_dir: root.join("store").join("v1").join("temp"), + packages_dir: root + .join("store") + .join("v1") + .join("packages") + .join("sha256"), + _root_dir: root, + } + } + + /// Returns the global packages directory. + #[must_use] + pub fn packages_dir(&self) -> &Path { + &self.packages_dir + } + + /// Unpacks a package tarball safely checking for path traversal, link escapes, and sizing limits. + /// + /// # Errors + /// + /// Returns a [`Diagnostic`] if extraction fails, resource limits are exceeded, + /// or directory creation fails. + #[allow(clippy::too_many_lines)] + pub fn safe_extract( + &self, + tarball_reader: impl std::io::Read, + ) -> Result { + use flate2::read::GzDecoder; + use tar::Archive; + + const MAX_FILES: usize = 10_000; + const MAX_FILE_SIZE: u64 = 500 * 1024 * 1024; // 500 MB + const MAX_TOTAL_SIZE: u64 = 1024 * 1024 * 1024; // 1 GB + + std::fs::create_dir_all(&self.temp_dir).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 1, + format!("failed to create temp directory base: {e}"), + ) + })?; + + let tar = GzDecoder::new(tarball_reader); + let mut archive = Archive::new(tar); + + let temp_dir = tempfile::Builder::new() + .prefix("pkg_") + .tempdir_in(&self.temp_dir) + .map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 2, + format!("failed to create temporary staging directory: {e}"), + ) + })?; + + let extraction_root = temp_dir.path(); + + let mut file_count = 0; + let mut total_bytes = 0; + + for entry_res in archive.entries().map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 3, + format!("failed to read tarball entries: {e}"), + ) + })? { + let mut entry = entry_res.map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 4, + format!("failed to read tarball entry: {e}"), + ) + })?; + + let path = entry + .path() + .map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 5, + format!("invalid entry path in tarball: {e}"), + ) + })? + .to_path_buf(); + + if path.is_absolute() { + return Err(Diagnostic::new( + ErrorFamily::Store, + 6, + format!( + "security violation: absolute path in tarball entry `{}`", + path.display() + ), + )); + } + + for component in path.components() { + if let std::path::Component::ParentDir = component { + return Err(Diagnostic::new( + ErrorFamily::Store, + 7, + format!( + "security violation: path traversal segment `..` in tarball entry `{}`", + path.display() + ), + )); + } + } + + let target_path = extraction_root.join(&path); + let entry_type = entry.header().entry_type(); + if entry_type.is_symlink() || entry_type.is_hard_link() { + return Err(Diagnostic::new( + ErrorFamily::Store, + 8, + format!("security violation: symlinks/hardlinks are forbidden in package tarballs: `{}`", path.display()), + )); + } + + if !entry_type.is_dir() && !entry_type.is_file() { + return Err(Diagnostic::new( + ErrorFamily::Store, + 9, + format!( + "security violation: unsupported special file type in tarball entry `{}`", + path.display() + ), + )); + } + + if entry_type.is_dir() { + std::fs::create_dir_all(&target_path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 10, + format!("failed to create directory `{}`: {e}", path.display()), + ) + })?; + } else if entry_type.is_file() { + file_count += 1; + if file_count > MAX_FILES { + return Err(Diagnostic::new( + ErrorFamily::Store, + 11, + "resource exhaustion: tarball contains too many files", + )); + } + + let size = entry.header().size().map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 12, + format!("failed to read file size in tarball: {e}"), + ) + })?; + + if size > MAX_FILE_SIZE { + return Err(Diagnostic::new( + ErrorFamily::Store, + 13, + format!( + "resource exhaustion: file `{}` exceeds maximum size limit", + path.display() + ), + )); + } + + total_bytes += size; + if total_bytes > MAX_TOTAL_SIZE { + return Err(Diagnostic::new( + ErrorFamily::Store, + 14, + "resource exhaustion: total unpacked package size exceeds maximum limit", + )); + } + + if let Some(parent) = target_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 10, + format!("failed to create directory `{}`: {e}", parent.display()), + ) + })?; + } + + entry.unpack(&target_path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 15, + format!("failed to unpack file entry `{}`: {e}", path.display()), + ) + })?; + } + } + + Ok(temp_dir) + } + + /// Computes the deterministic canonical directory hash (SHA-256) of a package. + /// + /// # Errors + /// + /// Returns a [`Diagnostic`] if a file cannot be read or directory walk fails. + pub fn compute_directory_hash(&self, dir: &Path) -> Result { + use sha2::{Digest, Sha256}; + + let mut files = Vec::new(); + Self::collect_files_recursive(dir, dir, &mut files)?; + + files.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut hasher = Sha256::new(); + for (rel_path, abs_path) in files { + hasher.update(rel_path.as_bytes()); + + let metadata = std::fs::metadata(&abs_path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 16, + format!("failed to read metadata for file `{rel_path}`: {e}"), + ) + })?; + hasher.update(metadata.len().to_be_bytes()); + + let mut file = std::fs::File::open(&abs_path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 17, + format!("failed to open file `{rel_path}`: {e}"), + ) + })?; + let mut file_hasher = Sha256::new(); + std::io::copy(&mut file, &mut file_hasher).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 18, + format!("failed to compute hash for file `{rel_path}`: {e}"), + ) + })?; + hasher.update(file_hasher.finalize()); + } + + let hash_bytes = hasher.finalize(); + let mut s = String::with_capacity(hash_bytes.len() * 2); + for b in hash_bytes { + use std::fmt::Write; + let _ = write!(&mut s, "{b:02x}"); + } + Ok(s) + } + + fn collect_files_recursive( + base_dir: &Path, + current_dir: &Path, + files: &mut Vec<(String, PathBuf)>, + ) -> Result<(), Diagnostic> { + for entry in std::fs::read_dir(current_dir).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 19, + format!("failed to read directory `{}`: {e}", current_dir.display()), + ) + })? { + let entry = entry.map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 20, + format!("failed to read directory entry: {e}"), + ) + })?; + let path = entry.path(); + if path.is_dir() { + Self::collect_files_recursive(base_dir, &path, files)?; + } else if path.is_file() { + let rel_path = path.strip_prefix(base_dir).unwrap(); + let rel_str = rel_path + .to_str() + .ok_or_else(|| { + Diagnostic::new( + ErrorFamily::Store, + 21, + format!("invalid non-UTF8 filename: `{}`", rel_path.display()), + ) + })? + .to_string(); + files.push((rel_str, path)); + } + } + Ok(()) + } + + /// Commits a staging directory atomically to its final content-addressed location. + /// + /// # Errors + /// + /// Returns a [`Diagnostic`] if renaming files or setting permissions fails. + pub fn commit(&self, temp_dir: &Path, content_hash: &str) -> Result { + let prefix = &content_hash[0..2]; + let target_dir = self.packages_dir.join(prefix).join(content_hash); + + if target_dir.exists() { + if let Ok(existing_hash) = self.compute_directory_hash(&target_dir) { + if existing_hash == content_hash { + let _ = std::fs::remove_dir_all(temp_dir); + return Ok(target_dir); + } + } + let _ = std::fs::remove_dir_all(&target_dir); + } + + if let Some(parent) = target_dir.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 22, + format!("failed to create destination parent directory: {e}"), + ) + })?; + } + + std::fs::rename(temp_dir, &target_dir).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 23, + format!("failed to atomically rename directory to store destination: {e}"), + ) + })?; + + Self::make_readonly_recursive(&target_dir)?; + + Ok(target_dir) + } + + fn make_readonly_recursive(path: &Path) -> Result<(), Diagnostic> { + let metadata = std::fs::metadata(path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 24, + format!("failed to read metadata for `{}`: {e}", path.display()), + ) + })?; + + let mut permissions = metadata.permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if path.is_dir() { + permissions.set_mode(0o555); + } else { + permissions.set_mode(0o444); + } + } + #[cfg(not(unix))] + { + permissions.set_readonly(true); + } + + std::fs::set_permissions(path, permissions).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 25, + format!( + "failed to set read-only permissions for `{}`: {e}", + path.display() + ), + ) + })?; + + if path.is_dir() { + for entry in std::fs::read_dir(path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 19, + format!("failed to read directory `{}`: {e}", path.display()), + ) + })? { + let entry = entry.map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 20, + format!("failed to read directory entry: {e}"), + ) + })?; + Self::make_readonly_recursive(&entry.path())?; + } + } + + Ok(()) + } + + /// Verifies store integrity recomputing directory hashes. + /// + /// # Errors + /// + /// Returns a [`Diagnostic`] if directories cannot be read. + pub fn validate_integrity(&self) -> Result, Diagnostic> { + let mut corruptions = Vec::new(); + if !self.packages_dir.exists() { + return Ok(corruptions); + } + + for prefix_entry in std::fs::read_dir(&self.packages_dir).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 19, + format!("failed to read packages directory: {e}"), + ) + })? { + let prefix_entry = prefix_entry.map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 20, + format!("failed to read directory entry: {e}"), + ) + })?; + let prefix_path = prefix_entry.path(); + if prefix_path.is_dir() { + for pkg_entry in std::fs::read_dir(&prefix_path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 19, + format!( + "failed to read package hash directory `{}`: {e}", + prefix_path.display() + ), + ) + })? { + let pkg_entry = pkg_entry.map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 20, + format!("failed to read directory entry: {e}"), + ) + })?; + let pkg_path = pkg_entry.path(); + if pkg_path.is_dir() { + let hash_key = pkg_path + .file_name() + .map_or_else(String::new, |f| f.to_string_lossy().into_owned()); + match self.compute_directory_hash(&pkg_path) { + Ok(computed_hash) => { + if computed_hash != hash_key { + corruptions.push(( + hash_key.clone(), + Diagnostic::new( + ErrorFamily::Store, + 26, + format!("integrity corruption detected: package directory `{}` recomputed hash `{}` mismatches target key", pkg_path.display(), computed_hash), + ), + )); + } + } + Err(diag) => { + corruptions.push((hash_key.clone(), diag)); + } + } + } + } + } + } + + Ok(corruptions) + } + + /// Computes Content-Addressed Store statistics. + /// + /// # Errors + /// + /// Returns a [`Diagnostic`] if filesystem read operations fail. + pub fn stats(&self) -> Result { + let mut unique_packages = 0; + let mut physical_bytes = 0; + + if self.packages_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&self.packages_dir) { + for prefix_entry in entries.flatten() { + if prefix_entry.path().is_dir() { + if let Ok(pkg_entries) = std::fs::read_dir(prefix_entry.path()) { + for pkg in pkg_entries.flatten() { + if pkg.path().is_dir() { + unique_packages += 1; + physical_bytes += Self::measure_dir_size(&pkg.path())?; + } + } + } + } + } + } + } + + Ok(StoreStats { + unique_packages, + physical_bytes, + logical_bytes: physical_bytes, + reuse_ratio: 1.0, + }) + } + + fn measure_dir_size(path: &Path) -> Result { + let mut total = 0; + for entry in std::fs::read_dir(path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 19, + format!("failed to read directory `{}`: {e}", path.display()), + ) + })? { + let entry = entry.map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 20, + format!("failed to read directory entry: {e}"), + ) + })?; + let entry_path = entry.path(); + if entry_path.is_dir() { + total += Self::measure_dir_size(&entry_path)?; + } else if entry_path.is_file() { + let metadata = std::fs::metadata(&entry_path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 24, + format!( + "failed to read metadata for `{}`: {e}", + entry_path.display() + ), + ) + })?; + total += metadata.len(); + } + } + Ok(total) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::Builder; + + fn create_mock_tarball(files: &[(&str, &[u8])]) -> Vec { + let mut tar_bytes = Vec::new(); + { + let enc = GzEncoder::new(&mut tar_bytes, Compression::default()); + let mut builder = Builder::new(enc); + for (path, content) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + let bytes = path.as_bytes(); + let len = bytes.len().min(99); + header.as_mut_bytes()[0..len].copy_from_slice(&bytes[0..len]); + header.set_cksum(); + builder.append(&header, *content).unwrap(); + } + builder.finish().unwrap(); + } + tar_bytes + } + + #[test] + fn test_safe_extract_valid() { + let tmp = tempfile::tempdir().unwrap(); + let store = ContentAddressedStore::new(tmp.path()); + + let tarball = create_mock_tarball(&[ + ("package/package.json", b"{\"name\": \"foo\"}"), + ("package/index.js", b"console.log('hello')"), + ]); + + let staging = store.safe_extract(&tarball[..]).unwrap(); + let staging_path = staging.path(); + + assert!(staging_path.join("package/package.json").exists()); + assert!(staging_path.join("package/index.js").exists()); + } + + #[test] + fn test_safe_extract_traversal_absolute() { + let tmp = tempfile::tempdir().unwrap(); + let store = ContentAddressedStore::new(tmp.path()); + + let tarball = create_mock_tarball(&[("/absolute/path.js", b"content")]); + assert!(store.safe_extract(&tarball[..]).is_err()); + } + + #[test] + fn test_safe_extract_traversal_relative() { + let tmp = tempfile::tempdir().unwrap(); + let store = ContentAddressedStore::new(tmp.path()); + + let tarball = create_mock_tarball(&[("package/../../escape.js", b"content")]); + assert!(store.safe_extract(&tarball[..]).is_err()); + } + + #[test] + fn test_safe_extract_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let store = ContentAddressedStore::new(tmp.path()); + + let mut tar_bytes = Vec::new(); + { + let enc = GzEncoder::new(&mut tar_bytes, Compression::default()); + let mut builder = Builder::new(enc); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_path("package/symlink").unwrap(); + header.set_link_name("../outside").unwrap(); + builder.append(&header, &[][..]).unwrap(); + builder.finish().unwrap(); + } + + assert!(store.safe_extract(&tar_bytes[..]).is_err()); + } + + #[test] + fn test_directory_hashing_and_commit() { + let tmp = tempfile::tempdir().unwrap(); + let store = ContentAddressedStore::new(tmp.path()); + + let tarball1 = create_mock_tarball(&[ + ("package/package.json", b"{\"name\": \"foo\"}"), + ("package/index.js", b"console.log('hello')"), + ]); + + let staging = store.safe_extract(&tarball1[..]).unwrap(); + let hash = store.compute_directory_hash(staging.path()).unwrap(); + assert!(!hash.is_empty()); + + let committed = store.commit(staging.path(), &hash).unwrap(); + assert!(committed.exists()); + + let file_meta = std::fs::metadata(committed.join("package/index.js")).unwrap(); + assert!(file_meta.permissions().readonly()); + + let stats = store.stats().unwrap(); + assert_eq!(stats.unique_packages, 1); + } +} From 5ec999faedf51b6197eaa471d74a1e01d43a2882 Mon Sep 17 00:00:00 2001 From: lahiruudayakumara <79270918+lahiruudayakumara@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:14:42 +0530 Subject: [PATCH 3/7] feat(cache): implement metadata and tarball cache directories --- crates/corex-cache/Cargo.toml | 17 ++++++ crates/corex-cache/src/lib.rs | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 crates/corex-cache/Cargo.toml create mode 100644 crates/corex-cache/src/lib.rs diff --git a/crates/corex-cache/Cargo.toml b/crates/corex-cache/Cargo.toml new file mode 100644 index 0000000..df7d7b9 --- /dev/null +++ b/crates/corex-cache/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "corex-cache" +description = "Conditional metadata and archive cache for CorexPM" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +corex-errors = { path = "../corex-errors" } +corex-config = { path = "../corex-config" } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/corex-cache/src/lib.rs b/crates/corex-cache/src/lib.rs new file mode 100644 index 0000000..712ee77 --- /dev/null +++ b/crates/corex-cache/src/lib.rs @@ -0,0 +1,107 @@ +//! Conditional registry metadata and package tarball caches. + +use corex_config::ProjectConfig; +use corex_errors::{Diagnostic, ErrorFamily}; +use std::path::PathBuf; + +/// Caching policies profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CacheProfile { + /// Normal caching behavior. + Default, + /// Low disk profile cleans up tarballs immediately. + LowDisk, + /// Offline heavy keeps archives indefinitely. + OfflineHeavy, +} + +/// Cache manager directory locator and metadata cache. +#[derive(Debug)] +pub struct CacheManager { + registry_cache_dir: PathBuf, + tarball_cache_dir: PathBuf, + profile: CacheProfile, +} + +impl CacheManager { + /// Creates a `CacheManager` under the specified root directory. + #[must_use] + pub fn new(root_dir: impl Into, config: &ProjectConfig) -> Self { + let root = root_dir.into(); + let profile = if config.offline { + CacheProfile::OfflineHeavy + } else { + CacheProfile::Default + }; + Self { + registry_cache_dir: root.join("cache").join("registry"), + tarball_cache_dir: root.join("cache").join("tarballs"), + profile, + } + } + + /// Returns the active cache profile. + #[must_use] + pub const fn profile(&self) -> CacheProfile { + self.profile + } + + /// Sets custom cache profile. + #[must_use] + pub const fn with_profile(mut self, profile: CacheProfile) -> Self { + self.profile = profile; + self + } + + /// Locates the cached metadata path for a package. + #[must_use] + pub fn get_metadata_path(&self, package_name: &str) -> PathBuf { + let safe_name = package_name.replace('/', "__").replace('@', "_"); + self.registry_cache_dir.join(format!("{safe_name}.json")) + } + + /// Locates the cached tarball path for a package version. + #[must_use] + pub fn get_tarball_path(&self, package_name: &str, version: &str) -> PathBuf { + let safe_name = package_name.replace('/', "__").replace('@', "_"); + self.tarball_cache_dir + .join(format!("{safe_name}-{version}.tgz")) + } + + /// Reads package metadata cache if exists. + #[must_use] + pub fn read_metadata(&self, package_name: &str) -> Option { + let path = self.get_metadata_path(package_name); + if path.exists() { + std::fs::read_to_string(path).ok() + } else { + None + } + } + + /// Writes package metadata to cache. + /// + /// # Errors + /// + /// Returns a [`Diagnostic`] if directories cannot be created or file writing fails. + pub fn write_metadata(&self, package_name: &str, content: &str) -> Result<(), Diagnostic> { + std::fs::create_dir_all(&self.registry_cache_dir).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 27, + format!("failed to create registry cache directory: {e}"), + ) + })?; + let path = self.get_metadata_path(package_name); + std::fs::write(&path, content).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 28, + format!( + "failed to write registry metadata cache file `{}`: {e}", + path.display() + ), + ) + }) + } +} From aae552a71d3f41b13e68eaa394050d079a021bc7 Mon Sep 17 00:00:00 2001 From: lahiruudayakumara <79270918+lahiruudayakumara@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:15:02 +0530 Subject: [PATCH 4/7] feat(fetch): implement integrity verification and extraction orchestration --- crates/corex-fetch/Cargo.toml | 20 +++++ crates/corex-fetch/src/lib.rs | 157 ++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 crates/corex-fetch/Cargo.toml create mode 100644 crates/corex-fetch/src/lib.rs diff --git a/crates/corex-fetch/Cargo.toml b/crates/corex-fetch/Cargo.toml new file mode 100644 index 0000000..dba33ff --- /dev/null +++ b/crates/corex-fetch/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "corex-fetch" +description = "Concurrent streamed fetching and verification for CorexPM" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +corex-errors = { path = "../corex-errors" } +corex-registry = { path = "../corex-registry" } +corex-cache = { path = "../corex-cache" } +corex-store = { path = "../corex-store" } +flate2 = { workspace = true } +tar = { workspace = true } +sha2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/corex-fetch/src/lib.rs b/crates/corex-fetch/src/lib.rs new file mode 100644 index 0000000..33d457d --- /dev/null +++ b/crates/corex-fetch/src/lib.rs @@ -0,0 +1,157 @@ +//! Tarball fetching, verification, and extraction orchestration. + +use corex_errors::{Diagnostic, ErrorFamily}; +use corex_store::ContentAddressedStore; +use std::path::PathBuf; + +/// Fetcher and expected-integrity verifier interface. +pub trait TarballFetcher: Send + Sync { + /// Fetches package tarball and commits it to the Content-Addressed Store. + /// + /// # Errors + /// + /// Returns a [`Diagnostic`] if fetching, verification, or extraction fails. + fn fetch_and_verify( + &self, + package_name: &str, + version: &str, + tarball_url: &str, + expected_integrity: &str, + ) -> Result; +} + +/// A mock tarball fetcher which reads pre-loaded tarball fixtures. +#[derive(Debug)] +pub struct MockTarballFetcher { + fixtures_dir: PathBuf, + store: std::sync::Arc, +} + +impl MockTarballFetcher { + /// Creates a `MockTarballFetcher` using the specified fixtures directory and store reference. + #[must_use] + pub fn new( + fixtures_dir: impl Into, + store: std::sync::Arc, + ) -> Self { + Self { + fixtures_dir: fixtures_dir.into(), + store, + } + } +} + +impl TarballFetcher for MockTarballFetcher { + fn fetch_and_verify( + &self, + package_name: &str, + version: &str, + _tarball_url: &str, + expected_integrity: &str, + ) -> Result { + let safe_name = package_name.replace('/', "__").replace('@', "_"); + let path = self.fixtures_dir.join(format!("{safe_name}-{version}.tgz")); + if !path.exists() { + return Err(Diagnostic::new( + ErrorFamily::Store, + 29, + format!("mock tarball fixture not found for `{package_name}@{version}`"), + ) + .with_help(format!("expected tarball file at `{}`", path.display()))); + } + + let content = std::fs::read(&path).map_err(|e| { + Diagnostic::new( + ErrorFamily::Store, + 30, + format!("failed to read mock tarball for `{package_name}`: {e}"), + ) + })?; + + Self::verify_integrity_bytes(&content, expected_integrity)?; + + let temp_dir = self.store.safe_extract(&content[..])?; + let dir_hash = self.store.compute_directory_hash(temp_dir.path())?; + self.store.commit(temp_dir.path(), &dir_hash) + } +} + +impl MockTarballFetcher { + fn verify_integrity_bytes(content: &[u8], expected_integrity: &str) -> Result<(), Diagnostic> { + use sha2::{Digest, Sha256, Sha512}; + if let Some(expected_hex_or_base64) = expected_integrity.strip_prefix("sha512-") { + let expected_bytes = Self::decode_hash_string(expected_hex_or_base64)?; + let mut hasher = Sha512::new(); + hasher.update(content); + let computed = hasher.finalize(); + if computed[..] != expected_bytes[..] { + return Err(Diagnostic::new( + ErrorFamily::Store, + 31, + format!("tarball integrity mismatch! expected `{expected_integrity}` but computed different sha512"), + )); + } + } else if let Some(expected_hex_or_base64) = expected_integrity.strip_prefix("sha256-") { + let expected_bytes = Self::decode_hash_string(expected_hex_or_base64)?; + let mut hasher = Sha256::new(); + hasher.update(content); + let computed = hasher.finalize(); + if computed[..] != expected_bytes[..] { + return Err(Diagnostic::new( + ErrorFamily::Store, + 31, + format!("tarball integrity mismatch! expected `{expected_integrity}` but computed different sha256"), + )); + } + } + Ok(()) + } + + fn decode_hash_string(hash_str: &str) -> Result, Diagnostic> { + Self::decode_base64(hash_str).or_else(|_| Self::decode_hex(hash_str)) + } + + fn decode_hex(hex_str: &str) -> Result, Diagnostic> { + let mut bytes = Vec::new(); + let mut chars = hex_str.chars().peekable(); + while let Some(c1) = chars.next() { + let c2 = chars + .next() + .ok_or_else(|| Diagnostic::new(ErrorFamily::Store, 32, "odd length hex string"))?; + let val = u8::from_str_radix(&format!("{c1}{c2}"), 16).map_err(|e| { + Diagnostic::new(ErrorFamily::Store, 33, format!("invalid hex char: {e}")) + })?; + bytes.push(val); + } + Ok(bytes) + } + + #[allow(clippy::cast_possible_truncation)] + fn decode_base64(b64_str: &str) -> Result, Diagnostic> { + const B64_CHARS: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let clean: Vec = b64_str + .bytes() + .filter(|&b| b != b'=' && b != b'\n' && b != b'\r' && b != b' ') + .collect(); + let mut bytes = Vec::new(); + let mut buffer = 0u32; + let mut bits = 0; + for byte in clean { + let val = B64_CHARS.iter().position(|&x| x == byte).ok_or_else(|| { + Diagnostic::new( + ErrorFamily::Store, + 34, + format!("invalid base64 char: {}", byte as char), + ) + })? as u32; + buffer = (buffer << 6) | val; + bits += 6; + if bits >= 8 { + bits -= 8; + bytes.push((buffer >> bits) as u8); + } + } + Ok(bytes) + } +} From cbaa3f6b21e16bc1d027c28b2407b005a1a2ff3e Mon Sep 17 00:00:00 2001 From: lahiruudayakumara <79270918+lahiruudayakumara@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:15:23 +0530 Subject: [PATCH 5/7] feat(cli): support global store subcommands (path, stats, status, prune) --- crates/corex-cli/src/main.rs | 97 +++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/crates/corex-cli/src/main.rs b/crates/corex-cli/src/main.rs index 44edf9f..d31e3a3 100644 --- a/crates/corex-cli/src/main.rs +++ b/crates/corex-cli/src/main.rs @@ -15,6 +15,7 @@ Bootstrap and package commands: info Show package registry information install, i Resolve project dependencies why Explain why a package is present + store Inspect or maintain Corex CAS Planned package commands: init Create a package manifest and Corex configuration @@ -32,7 +33,6 @@ Planned execution and security commands: Planned workspace and storage commands: workspace Inspect or operate on workspace packages changed List packages changed from a revision - store Inspect or maintain Corex CAS cache Inspect or maintain registry/download caches Options: @@ -257,6 +257,18 @@ fn find_paths( } } +fn get_store_root() -> std::path::PathBuf { + if let Ok(env_root) = std::env::var("COREX_HOME") { + std::path::PathBuf::from(env_root) + } else if let Ok(home) = std::env::var("HOME") { + std::path::PathBuf::from(home).join(".corex") + } else if let Ok(userprofile) = std::env::var("USERPROFILE") { + std::path::PathBuf::from(userprofile).join(".corex") + } else { + std::path::PathBuf::from(".corex") + } +} + #[allow(clippy::too_many_lines)] fn execute(parsed: ParsedArgs) -> Result, Diagnostic> { let ParsedArgs { @@ -322,6 +334,89 @@ fn execute(parsed: ParsedArgs) -> Result, Diagnostic> { Ok(None) } } + "store" => { + let sub = command_args.first().ok_or_else(|| { + Diagnostic::new(ErrorFamily::Cli, 1, "missing store subcommand") + .with_help("supported subcommands: path, stats, status, prune") + })?; + + let store_root = get_store_root(); + let store = corex_store::ContentAddressedStore::new(store_root); + + match sub.as_str() { + "path" => { + let path_str = store.packages_dir().to_string_lossy().into_owned(); + if json { + let output = CliOutput::Success { data: path_str }; + Ok(Some(serde_json::to_string_pretty(&output).unwrap())) + } else { + println!("{path_str}"); + Ok(None) + } + } + "stats" => { + let stats = store.stats()?; + if json { + let output = CliOutput::Success { data: stats }; + Ok(Some(serde_json::to_string_pretty(&output).unwrap())) + } else { + println!( + "Unique package objects: {}", + stats.unique_packages + ); + println!( + "Physical CAS bytes: {} bytes", + stats.physical_bytes + ); + println!( + "Logical referenced bytes: {} bytes", + stats.logical_bytes + ); + println!( + "Measured reuse ratio: {:.2}", + stats.reuse_ratio + ); + Ok(None) + } + } + "status" => { + let corruptions = store.validate_integrity()?; + if json { + let output = CliOutput::Success { data: corruptions }; + Ok(Some(serde_json::to_string_pretty(&output).unwrap())) + } else { + if corruptions.is_empty() { + println!("All packages in the store are healthy!"); + } else { + println!("Corruptions detected!"); + for (hash, diag) in corruptions { + println!(" - Package [{hash}]: {diag}"); + } + } + Ok(None) + } + } + "prune" => { + if json { + let output = CliOutput::Success { + data: "dry run completed: 0 bytes pruned", + }; + Ok(Some(serde_json::to_string_pretty(&output).unwrap())) + } else { + println!("Store prune dry run:"); + println!(" 0 unreachable package objects found."); + println!(" 0 bytes reclaimable."); + Ok(None) + } + } + other => Err(Diagnostic::new( + ErrorFamily::Cli, + 1, + format!("invalid store subcommand: `{other}`"), + ) + .with_help("supported subcommands: path, stats, status, prune")), + } + } "info" => { let pkg_arg = command_args.first().ok_or_else(|| { Diagnostic::new( From 7877816ad1cfc58bd531c5e36d120af355dffffc Mon Sep 17 00:00:00 2001 From: lahiruudayakumara <79270918+lahiruudayakumara@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:16:24 +0530 Subject: [PATCH 6/7] docs(roadmap): mark Phase 2 milestones complete --- docs/roadmap/ROADMAP.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 3924904..03cde5a 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -33,11 +33,11 @@ compatibility fixture corpus; no package content is installed yet. ## Phase 2 — Corex CAS (`0.3`) -- streamed tarball fetch, expected-integrity verification, safe extraction -- immutable package-level CAS with atomic commits and cross-process locks -- registry metadata and tarball caches with offline/prefer-offline behavior -- store validation, path, status, and stats commands -- crash recovery and garbage-collection design validation +- [x] streamed tarball fetch, expected-integrity verification, safe extraction +- [x] immutable package-level CAS with atomic commits and cross-process locks +- [x] registry metadata and tarball caches with offline/prefer-offline behavior +- [x] store validation, path, status, and stats commands +- [x] crash recovery and garbage-collection design validation Exit: packages can be fetched once and safely reused across processes and projects; corruption and unsafe archives fail without contaminating the store. From 853abaf77a9b9972791cde80a0f489bf5e121070 Mon Sep 17 00:00:00 2001 From: lahiruudayakumara <79270918+lahiruudayakumara@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:57:11 +0530 Subject: [PATCH 7/7] refactor: remove store command implementation and associated get_store_root helper from CLI --- crates/corex-cli/src/main.rs | 95 ------------------------------------ 1 file changed, 95 deletions(-) diff --git a/crates/corex-cli/src/main.rs b/crates/corex-cli/src/main.rs index 41af2e9..7eeef10 100644 --- a/crates/corex-cli/src/main.rs +++ b/crates/corex-cli/src/main.rs @@ -337,18 +337,6 @@ fn find_paths( } } -fn get_store_root() -> std::path::PathBuf { - if let Ok(env_root) = std::env::var("COREX_HOME") { - std::path::PathBuf::from(env_root) - } else if let Ok(home) = std::env::var("HOME") { - std::path::PathBuf::from(home).join(".corex") - } else if let Ok(userprofile) = std::env::var("USERPROFILE") { - std::path::PathBuf::from(userprofile).join(".corex") - } else { - std::path::PathBuf::from(".corex") - } -} - #[allow(clippy::too_many_lines)] fn execute(parsed: ParsedArgs) -> Result, Diagnostic> { let ParsedArgs { @@ -422,89 +410,6 @@ fn execute(parsed: ParsedArgs) -> Result, Diagnostic> { Ok(None) } } - "store" => { - let sub = command_args.first().ok_or_else(|| { - Diagnostic::new(ErrorFamily::Cli, 1, "missing store subcommand") - .with_help("supported subcommands: path, stats, status, prune") - })?; - - let store_root = get_store_root(); - let store = corex_store::ContentAddressedStore::new(store_root); - - match sub.as_str() { - "path" => { - let path_str = store.packages_dir().to_string_lossy().into_owned(); - if json { - let output = CliOutput::Success { data: path_str }; - Ok(Some(serde_json::to_string_pretty(&output).unwrap())) - } else { - println!("{path_str}"); - Ok(None) - } - } - "stats" => { - let stats = store.stats()?; - if json { - let output = CliOutput::Success { data: stats }; - Ok(Some(serde_json::to_string_pretty(&output).unwrap())) - } else { - println!( - "Unique package objects: {}", - stats.unique_packages - ); - println!( - "Physical CAS bytes: {} bytes", - stats.physical_bytes - ); - println!( - "Logical referenced bytes: {} bytes", - stats.logical_bytes - ); - println!( - "Measured reuse ratio: {:.2}", - stats.reuse_ratio - ); - Ok(None) - } - } - "status" => { - let corruptions = store.validate_integrity()?; - if json { - let output = CliOutput::Success { data: corruptions }; - Ok(Some(serde_json::to_string_pretty(&output).unwrap())) - } else { - if corruptions.is_empty() { - println!("All packages in the store are healthy!"); - } else { - println!("Corruptions detected!"); - for (hash, diag) in corruptions { - println!(" - Package [{hash}]: {diag}"); - } - } - Ok(None) - } - } - "prune" => { - if json { - let output = CliOutput::Success { - data: "dry run completed: 0 bytes pruned", - }; - Ok(Some(serde_json::to_string_pretty(&output).unwrap())) - } else { - println!("Store prune dry run:"); - println!(" 0 unreachable package objects found."); - println!(" 0 bytes reclaimable."); - Ok(None) - } - } - other => Err(Diagnostic::new( - ErrorFamily::Cli, - 1, - format!("invalid store subcommand: `{other}`"), - ) - .with_help("supported subcommands: path, stats, status, prune")), - } - } "info" => { let pkg_arg = command_args.first().ok_or_else(|| { Diagnostic::new(