Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughRefactor to thread a single mutable Changes
Sequence Diagram(s)mermaid Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces a KeyCache to ReadOptions to optimize decryption performance by caching derived keys and adds a HashedPassword struct for efficient archive creation. The API has been updated to require a mutable reference to ReadOptions when reading entries. Review feedback identifies several locations in the CLI where ReadOptions is instantiated locally, which prevents cache reuse and leads to redundant, expensive hashing operations. Furthermore, the removal of standard trait derivations (such as Eq and Hash) from the options structs is flagged as a breaking change for library consumers that should be mitigated with manual implementations.
| let data_kind = entry.header().data_kind(); | ||
| let path = entry.header().path(); | ||
| let path_str = path.as_str(); | ||
| let mut read_options = ReadOptions::with_password(password); |
There was a problem hiding this comment.
Creating a new ReadOptions instance inside compare_entry prevents the KeyCache from being reused across different entries in the archive. Since compare_entry is called for every entry during a diff operation, this results in redundant and expensive password hashing (e.g., Argon2) for every file if they share the same encryption parameters. This significantly impacts performance, especially for archives with many small encrypted files. Consider passing a mutable reference to a shared ReadOptions object from the caller.
| return Ok(()); | ||
| }; | ||
|
|
||
| let mut read_options = ReadOptions::with_password(password); |
There was a problem hiding this comment.
Instantiating ReadOptions within extract_entry defeats the purpose of the KeyCache for NormalEntry decryption across the archive. Because extract_entry is invoked per file, the cache is lost between calls, forcing a full key derivation (Argon2/PBKDF2) for every entry. This is a major performance bottleneck. For sequential extraction, a single ReadOptions should be reused. For parallel extraction (via Rayon), consider using thread-local ReadOptions or cloning a 'warmed' instance if applicable.
| if collect.link_target { | ||
| entry | ||
| .reader(ReadOptions::with_password(password)) | ||
| .reader(&mut ReadOptions::with_password(password)) |
There was a problem hiding this comment.
ReadOptions is instantiated here (and again at line 442) for every symbolic or hard link encountered during a long listing. This triggers expensive key derivation twice per link entry. TableRow::from_entry should be refactored to accept a &mut ReadOptions from the caller (e.g., list_archive or run_list_archive), where a persistent options object already exists.
| /// The compression algorithm and cipher mode are stored in the archive metadata, so you | ||
| /// only need to provide the password. | ||
| #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] | ||
| #[derive(Clone, Debug)] |
There was a problem hiding this comment.
The removal of Eq, PartialEq, Ord, PartialOrd, and Hash from WriteOptions (and similarly from ReadOptions at line 867) is a breaking change for library consumers who may rely on these traits for comparing configurations or using them as keys in collections. While KeyCache makes automatic derivation difficult for ReadOptions, HashedPassword and WriteOptions should still implement these traits. For ReadOptions, consider a manual implementation of PartialEq and Hash that ignores the internal cache state.
| /// Moves the hit entry to front (MRU) on success. | ||
| pub(crate) fn get(&mut self, phsf: &str) -> Option<Output> { | ||
| if let Some(pos) = self.entries.iter().position(|(k, _)| k == phsf) { | ||
| let entry = self.entries.remove(pos); |
| if let Some(pos) = self.entries.iter().position(|(k, _)| k == phsf) { | ||
| let entry = self.entries.remove(pos); | ||
| let output = entry.1; | ||
| self.entries.insert(0, entry); |
| if self.entries.len() >= Self::CAPACITY { | ||
| self.entries.pop(); | ||
| } | ||
| self.entries.insert(0, (phsf, key)); |
|
|
||
| #[test] | ||
| fn write_options_round_trip_hashed_password() { | ||
| let hashed = HashedPassword::new(b"secret", HashAlgorithm::argon2id()).unwrap(); |
|
|
||
| #[test] | ||
| fn hashed_password_last_wins_over_raw() { | ||
| let hashed = HashedPassword::new(b"secret", HashAlgorithm::argon2id()).unwrap(); |
|
|
||
| #[test] | ||
| fn round_trip_with_hashed_password_cbc() -> io::Result<()> { | ||
| let password = b"test_password"; |
|
|
||
| #[test] | ||
| fn round_trip_with_hashed_password_pbkdf2() -> io::Result<()> { | ||
| let password = b"test_password"; |
|
|
||
| #[test] | ||
| fn wrong_password_returns_error() -> io::Result<()> { | ||
| let hashed = HashedPassword::new(b"correct_password", HashAlgorithm::argon2id())?; |
|
|
||
| #[test] | ||
| fn hashed_password_debug_does_not_leak_key() { | ||
| let hp = HashedPassword::new(b"secret", HashAlgorithm::argon2id()).unwrap(); |
|
|
||
| #[test] | ||
| fn write_options_hashed_round_trip_no_panic() { | ||
| let hashed = HashedPassword::new(b"password", HashAlgorithm::argon2id()).unwrap(); |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
lib/benches/create_extract.rs (1)
70-78: KeepReadOptionssetup out of the benchmarked path.
item.reader(&mut ReadOptions::with_password(...))makes this benchmark measureReadOptionsconstruction as well as slice decoding, unlikebench_read_archiveabove. Hoist a locallet mut read_options = ...;once perb.iteriteration and pass that intoreader(...).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/benches/create_extract.rs` around lines 70 - 78, The benchmark currently constructs ReadOptions for each entry via item.reader(&mut ReadOptions::with_password(Some("password"))) which pollutes the timing; inside the b.iter(|| { ... }) closure, hoist a single let mut read_options = ReadOptions::with_password(Some("password")); immediately after creating the reader (before the for item in reader.entries_slice() loop) and replace the inline construction with item.reader(&mut read_options). This keeps ReadOptions setup out of the per-entry measured path while leaving the rest of the bench_read_archive logic unchanged.lib/src/entry/write.rs (1)
38-62: Define precedence betweenhashed_password()andhash_algorithm().In the
CipherPassword::Hashedbranch,cipher.hash_algorithmis ignored completely. That means a caller can configure one hash algorithm inWriteOptionswhile the embeddedphsfcame from another, and the archive will silently use the embedded hash. Either reject that mixed configuration or document thathashed_password()wins.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lib/src/entry/write.rs` around lines 38 - 62, The CipherPassword::Hashed branch currently ignores cipher.hash_algorithm, allowing mismatched hash algorithms between the provided WriteOptions and the embedded phsf; update the CipherPassword::Hashed arm in the match to enforce a clear precedence: either (preferred) validate that the embedded phsf's algorithm equals cipher.hash_algorithm and return an io::Error if they differ, or explicitly document and accept that hashed_password() (the embedded phsf) wins and override cipher.hash_algorithm accordingly; locate the check in the CipherPassword::Hashed match arm (symbols: CipherPassword::Hashed, phsf, cipher.hash_algorithm, hashed_password()) and implement the validation and error return (or the override) consistently.cli/src/command/list.rs (1)
428-448: Per-entryReadOptionscreation loses cache benefit for link targets.Each symlink/hardlink target read creates a fresh
ReadOptions, so theKeyCachewon't be reused across entries. If archives contain many encrypted symlinks/hardlinks, this could cause redundant password derivation.Consider passing an existing
&mut ReadOptionsintofrom_entryif this becomes a performance concern for encrypted archives with many links.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@cli/src/command/list.rs` around lines 428 - 448, Per-entry creation of ReadOptions via ReadOptions::with_password inside the entry.reader calls (used in the DataKind::SymLink and DataKind::HardLink branches) defeats KeyCache reuse and forces repeated password derivation; instead create and reuse a single mutable ReadOptions (e.g. let mut read_opts = ReadOptions::with_password(password)) and pass &mut read_opts into entry.reader/from_entry so all link target reads share the same ReadOptions/KeyCache; update the call sites that currently call ReadOptions::with_password(password) to use the shared &mut ReadOptions and ensure from_entry signatures accept &mut ReadOptions if needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@cli/src/command/core.rs`:
- Around line 1191-1192: The loop currently constructs a new ReadOptions inside
the s.entries(&mut read_options)? call which causes ReadEntry::Solid branches to
lose KeyCache between solid blocks; instead thread a single mutable ReadOptions
from run_transform_entry() into TransformStrategy::transform() and stop
rebuilding it per-entry—modify run_transform_entry() to create and pass &mut
ReadOptions into calls that invoke TransformStrategy::transform(), update
TransformStrategy::transform() signature to accept &mut ReadOptions, and remove
the local ReadOptions::with_password(password) construction inside the s.entries
loop so the same ReadOptions (and its KeyCache) is reused across solid
transforms.
In `@cli/src/command/core/archive_source.rs`:
- Line 5: The unused-import warning comes from importing ReadOptions
unconditionally; make that import conditional on the memmap feature by removing
ReadOptions from the combined use and adding a separate feature-gated import
like #[cfg(feature = "memmap")] use pna::ReadOptions; so NormalEntry and
ReadEntry remain imported normally and ReadOptions is only imported when the
memmap-related code (which references ReadOptions) is compiled.
In `@lib/src/entry.rs`:
- Around line 435-446: Public read APIs were changed to accept &mut ReadOptions
which breaks source compatibility; revert the signatures of SolidEntry::entries
and NormalEntry::reader back to take &ReadOptions (immutable borrow) and instead
give ReadOptions an interior-mutable cache (e.g., RefCell/Mutex/Atomic/Cell
wrapper for the existing cache type). Update places that previously mutated
option.cache inside entries/reader to use the interior-mutable API
(borrow_mut()/lock()/get()/etc.) so the public method signatures stay &self,
&ReadOptions while still allowing cache updates. Ensure all call sites use the
immutable &ReadOptions and that the cache field name and type in ReadOptions
carry the interior-mutable wrapper so no external API changes are required.
In `@lib/src/entry/options.rs`:
- Around line 175-177: The hashed-password branch desynchronizes the builder's
hash_algorithm because hashed_password() swaps only the password variant without
updating self.hash_algorithm; fix by ensuring the HashedPassword value carries
its algorithm (or can derive it) and update the builder's hash_algorithm when
you replace the password variant. Concretely: extend HashedPassword (or the
result of crate::hash::new_hashed_password / HashedPassword::new) to include the
actual HashAlgorithm, have hashed_password() set self.hash_algorithm =
hashed.algorithm (or call a getter) when switching to the hashed branch, and
make into_builder()/password() use that algorithm so round-trips preserve the
KDF; apply the same change at the other occurrences noted (around the other
hashed_password/password conversions).
---
Nitpick comments:
In `@cli/src/command/list.rs`:
- Around line 428-448: Per-entry creation of ReadOptions via
ReadOptions::with_password inside the entry.reader calls (used in the
DataKind::SymLink and DataKind::HardLink branches) defeats KeyCache reuse and
forces repeated password derivation; instead create and reuse a single mutable
ReadOptions (e.g. let mut read_opts = ReadOptions::with_password(password)) and
pass &mut read_opts into entry.reader/from_entry so all link target reads share
the same ReadOptions/KeyCache; update the call sites that currently call
ReadOptions::with_password(password) to use the shared &mut ReadOptions and
ensure from_entry signatures accept &mut ReadOptions if needed.
In `@lib/benches/create_extract.rs`:
- Around line 70-78: The benchmark currently constructs ReadOptions for each
entry via item.reader(&mut ReadOptions::with_password(Some("password"))) which
pollutes the timing; inside the b.iter(|| { ... }) closure, hoist a single let
mut read_options = ReadOptions::with_password(Some("password")); immediately
after creating the reader (before the for item in reader.entries_slice() loop)
and replace the inline construction with item.reader(&mut read_options). This
keeps ReadOptions setup out of the per-entry measured path while leaving the
rest of the bench_read_archive logic unchanged.
In `@lib/src/entry/write.rs`:
- Around line 38-62: The CipherPassword::Hashed branch currently ignores
cipher.hash_algorithm, allowing mismatched hash algorithms between the provided
WriteOptions and the embedded phsf; update the CipherPassword::Hashed arm in the
match to enforce a clear precedence: either (preferred) validate that the
embedded phsf's algorithm equals cipher.hash_algorithm and return an io::Error
if they differ, or explicitly document and accept that hashed_password() (the
embedded phsf) wins and override cipher.hash_algorithm accordingly; locate the
check in the CipherPassword::Hashed match arm (symbols: CipherPassword::Hashed,
phsf, cipher.hash_algorithm, hashed_password()) and implement the validation and
error return (or the override) consistently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5a863a80-dcb0-4ea0-9df6-ee230cbf7941
📒 Files selected for processing (35)
cli/src/command/core.rscli/src/command/core/archive_source.rscli/src/command/diff.rscli/src/command/extract.rscli/src/command/list.rscli/tests/cli/stdio/option_ignore_zeros.rscli/tests/cli/update/no_timestamp_archive.rscli/tests/cli/utils/archive.rsfuzz/fuzz_targets/aes_cbc.rsfuzz/fuzz_targets/aes_ctr.rsfuzz/fuzz_targets/camellia_cbc.rsfuzz/fuzz_targets/camellia_ctr.rsfuzz/fuzz_targets/split_archive.rslib/README.mdlib/benches/create_extract.rslib/examples/async_io.rslib/examples/change_compression_method.rslib/src/archive.rslib/src/archive/read.rslib/src/archive/read/slice.rslib/src/archive/write.rslib/src/entry.rslib/src/entry/builder.rslib/src/entry/key_cache.rslib/src/entry/options.rslib/src/entry/read.rslib/src/entry/write.rslib/src/hash.rslib/src/lib.rslib/tests/extract_compatibility.rslib/tests/extract_multipart_compatibility.rslib/tests/extract_solid_compatibility.rslib/tests/hashed_password.rslib/tests/security.rspna/README.md
| let mut read_options = ReadOptions::with_password(password); | ||
| for n in s.entries(&mut read_options)? { |
There was a problem hiding this comment.
Reuse one ReadOptions across solid transforms.
These branches still create a fresh ReadOptions per ReadEntry::Solid, so unsolid/keep-solid rewrites lose the new KeyCache between solid blocks and re-run the KDF for every block. Thread a shared &mut ReadOptions from run_transform_entry() into TransformStrategy::transform() instead of rebuilding it here.
♻️ Minimal sketch
trait TransformStrategy {
- fn transform<W, T, F>(archive: &mut Archive<W>, password: Option<&[u8]>, read_entry: io::Result<ReadEntry<T>>, transformer: F) -> io::Result<()>
+ fn transform<W, T, F>(archive: &mut Archive<W>, read_options: &mut ReadOptions, read_entry: io::Result<ReadEntry<T>>, transformer: F) -> io::Result<()>
}
...
- let password = password_provider();
+ let mut read_options = ReadOptions::with_password(password_provider());
...
- |entry| Transform::transform(&mut out_archive, password, entry, &mut processor),
+ |entry| Transform::transform(&mut out_archive, &mut read_options, entry, &mut processor),Also applies to: 1236-1237
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@cli/src/command/core.rs` around lines 1191 - 1192, The loop currently
constructs a new ReadOptions inside the s.entries(&mut read_options)? call which
causes ReadEntry::Solid branches to lose KeyCache between solid blocks; instead
thread a single mutable ReadOptions from run_transform_entry() into
TransformStrategy::transform() and stop rebuilding it per-entry—modify
run_transform_entry() to create and pass &mut ReadOptions into calls that invoke
TransformStrategy::transform(), update TransformStrategy::transform() signature
to accept &mut ReadOptions, and remove the local
ReadOptions::with_password(password) construction inside the s.entries loop so
the same ReadOptions (and its KeyCache) is reused across solid transforms.
| use std::{fs, io}; | ||
|
|
||
| use pna::{NormalEntry, ReadEntry}; | ||
| use pna::{NormalEntry, ReadEntry, ReadOptions}; |
There was a problem hiding this comment.
Feature-gate the ReadOptions import to fix unused import warning.
The ReadOptions import is only used within the #[cfg(feature = "memmap")] block at lines 93-98. On WebAssembly targets (which don't use memmap), this causes an unused import warning that appears in CI.
🔧 Proposed fix
-use pna::{NormalEntry, ReadEntry, ReadOptions};
+use pna::{NormalEntry, ReadEntry};
+#[cfg(feature = "memmap")]
+use pna::ReadOptions;🧰 Tools
🪛 GitHub Actions: Build Webassembly
[warning] 5-5: unused import: ReadOptions (#[warn(unused_imports)]).
🪛 GitHub Check: Test WebAssembly (nightly, wasm32-unknown-emscripten)
[warning] 5-5:
unused import: ReadOptions
🪛 GitHub Check: Test WebAssembly (nightly, wasm32-wasip1)
[warning] 5-5:
unused import: ReadOptions
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@cli/src/command/core/archive_source.rs` at line 5, The unused-import warning
comes from importing ReadOptions unconditionally; make that import conditional
on the memmap feature by removing ReadOptions from the combined use and adding a
separate feature-gated import like #[cfg(feature = "memmap")] use
pna::ReadOptions; so NormalEntry and ReadEntry remain imported normally and
ReadOptions is only imported when the memmap-related code (which references
ReadOptions) is compiled.
| pub fn entries( | ||
| &self, | ||
| password: Option<&[u8]>, | ||
| option: &mut ReadOptions, | ||
| ) -> io::Result<impl Iterator<Item = io::Result<NormalEntry>> + '_> { | ||
| let reader = decrypt_reader( | ||
| ChainReader::new(self.data.iter().map(AsRef::as_ref as fn(&T) -> &[u8])), | ||
| self.header.encryption, | ||
| self.header.cipher_mode, | ||
| self.phsf.as_deref(), | ||
| password, | ||
| option.password.as_deref(), | ||
| &mut option.cache, | ||
| )?; |
There was a problem hiding this comment.
Keep the public read APIs source-compatible.
Switching SolidEntry::entries and NormalEntry::reader to &mut ReadOptions is a breaking change for existing callers and forces exclusive borrowing through the whole read path. If this release is meant to stay semver-compatible, the cache needs interior mutability so these public signatures can remain read-only.
Also applies to: 1038-1052
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/src/entry.rs` around lines 435 - 446, Public read APIs were changed to
accept &mut ReadOptions which breaks source compatibility; revert the signatures
of SolidEntry::entries and NormalEntry::reader back to take &ReadOptions
(immutable borrow) and instead give ReadOptions an interior-mutable cache (e.g.,
RefCell/Mutex/Atomic/Cell wrapper for the existing cache type). Update places
that previously mutated option.cache inside entries/reader to use the
interior-mutable API (borrow_mut()/lock()/get()/etc.) so the public method
signatures stay &self, &ReadOptions while still allowing cache updates. Ensure
all call sites use the immutable &ReadOptions and that the cache field name and
type in ReadOptions carry the interior-mutable wrapper so no external API
changes are required.
| pub fn new(password: impl AsRef<[u8]>, hash_algorithm: HashAlgorithm) -> io::Result<Self> { | ||
| crate::hash::new_hashed_password(password.as_ref(), hash_algorithm) | ||
| } |
There was a problem hiding this comment.
hashed_password() can desynchronize the builder’s hash algorithm.
HashedPassword::new accepts any HashAlgorithm, but hashed_password() only swaps the password variant and leaves self.hash_algorithm unchanged. With a non-default hashed password, into_builder() will therefore round-trip the wrong algorithm, and switching back to .password(...) later can silently change the archive’s KDF. The hashed branch should carry or derive the algorithm it actually represents.
Also applies to: 723-723, 803-805, 842-849
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lib/src/entry/options.rs` around lines 175 - 177, The hashed-password branch
desynchronizes the builder's hash_algorithm because hashed_password() swaps only
the password variant without updating self.hash_algorithm; fix by ensuring the
HashedPassword value carries its algorithm (or can derive it) and update the
builder's hash_algorithm when you replace the password variant. Concretely:
extend HashedPassword (or the result of crate::hash::new_hashed_password /
HashedPassword::new) to include the actual HashAlgorithm, have hashed_password()
set self.hash_algorithm = hashed.algorithm (or call a getter) when switching to
the hashed branch, and make into_builder()/password() use that algorithm so
round-trips preserve the KDF; apply the same change at the other occurrences
noted (around the other hashed_password/password conversions).
| #[derive(Clone, Debug)] | ||
| pub struct WriteOptions { |
There was a problem hiding this comment.
Restore the removed trait impls on the public option types.
cargo-semver-checks is already failing because downstream code can no longer compare, order, or hash WriteOptions, WriteOptionsBuilder, ReadOptions, and ReadOptionsBuilder. If these types need to remain semver-compatible, please keep those impls and treat runtime-only state like ReadOptions.cache as non-semantic for equality/hash.
Also applies to: 691-692, 867-871, 936-937
🧰 Tools
🪛 GitHub Actions: semver-checks
[error] 636-636: cargo-semver-checks failed: derive_trait_impl_removed — type WriteOptions no longer derives Eq, PartialEq, Ord, PartialOrd, and Hash.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
.github/workflows/webassembly.yml (1)
93-93: Optional: centralize duplicatedRUSTFLAGSfor emscripten steps.Line 93 and Line 100 repeat the same flag string. Consider moving it to a shared
envscope (job-level or YAML anchor) to prevent future drift.Also applies to: 100-100
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/webassembly.yml at line 93, Extract the duplicated RUSTFLAGS value used in the emscripten steps and centralize it (e.g., define RUSTFLAGS at the job-level env or create a YAML anchor) so both occurrences reference the single definition; update the steps that currently set RUSTFLAGS to instead inherit the job env or reference the anchor to avoid duplication and future drift (look for the RUSTFLAGS string "-C link-arg=-sINITIAL_MEMORY=512MB -C link-arg=-sTOTAL_STACK=16MB -C link-arg=-sALLOW_MEMORY_GROWTH=1" and the emscripten-related steps that currently set it).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In @.github/workflows/webassembly.yml:
- Line 93: Extract the duplicated RUSTFLAGS value used in the emscripten steps
and centralize it (e.g., define RUSTFLAGS at the job-level env or create a YAML
anchor) so both occurrences reference the single definition; update the steps
that currently set RUSTFLAGS to instead inherit the job env or reference the
anchor to avoid duplication and future drift (look for the RUSTFLAGS string "-C
link-arg=-sINITIAL_MEMORY=512MB -C link-arg=-sTOTAL_STACK=16MB -C
link-arg=-sALLOW_MEMORY_GROWTH=1" and the emscripten-related steps that
currently set it).
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: db14ae4b-ccb9-4f20-a5f1-8c9630660658
📒 Files selected for processing (1)
.github/workflows/webassembly.yml
a28ae05 to
556c0ac
Compare
cb8595c to
160aa3f
Compare
0c0f25a to
e9c68a0
Compare
e9c68a0 to
2d510ae
Compare
Summary by CodeRabbit
New Features
Refactor
Documentation
Tests