From 4fbc1278b64df7a670a5009b27b78395ca0697dc Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:15:45 +0700 Subject: [PATCH 1/7] fix(describe): enforce max search limit and validate commit --- src/command/describe.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/command/describe.rs b/src/command/describe.rs index 053589a..add9f48 100644 --- a/src/command/describe.rs +++ b/src/command/describe.rs @@ -2,9 +2,11 @@ use crate::command::Command; use crate::domain::hash::Hash; use crate::domain::object::Object; use crate::error::VctrlError; -use crate::storage::traits::{ObjectStore, RefStore}; +use crate::storage::traits::{ObjectStore, ObjectStoreExt, RefStore}; use std::collections::{HashSet, VecDeque}; +const MAX_DESCRIBE_SEARCH: usize = 100_000; + pub struct Describe { pub commit_hash: Hash, pub max_commits_to_search: usize, @@ -18,6 +20,15 @@ impl Command for Describe { store: &mut dyn ObjectStore, refs: &mut dyn RefStore, ) -> Result, VctrlError> { + if self.max_commits_to_search > MAX_DESCRIBE_SEARCH { + return Err(VctrlError::Other(format!( + "max_commits_to_search must be <= {}", + MAX_DESCRIBE_SEARCH + ))); + } + + store.get_commit(&self.commit_hash)?; + let tag_refs = refs.list_refs("refs/tags/")?; let mut tag_map: Vec<(Hash, String)> = Vec::new(); for ref_name in &tag_refs { From c86a23bf3156d1b8ba8ec21e5e0254d7b22c9072 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:15:45 +0700 Subject: [PATCH 2/7] refactor(log): remove encoder and hasher from LogGraph --- src/command/log_graph.rs | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/command/log_graph.rs b/src/command/log_graph.rs index d9fe5b6..79da44e 100644 --- a/src/command/log_graph.rs +++ b/src/command/log_graph.rs @@ -19,8 +19,6 @@ pub struct GraphCommit { pub struct LogGraph { pub head: Hash, - pub encoder: Box, - pub hasher: Box, } impl Command for LogGraph { @@ -32,30 +30,24 @@ impl Command for LogGraph { _refs: &mut dyn RefStore, ) -> Result, VctrlError> { let walk = RevWalk::new(store, &[self.head])?; - let commits: Vec = walk.collect::, _>>()?; + let commit_pairs: Vec<(Hash, Commit)> = walk.collect::, _>>()?; - let mut hashes = Vec::with_capacity(commits.len()); - for c in &commits { - let mut buf = Vec::new(); - self.encoder.encode_commit(c, &mut buf)?; - let hash = self.hasher.hash_commit_encoded(&buf); - hashes.push(hash); - } - - let hash_to_idx: HashMap = - hashes.iter().enumerate().map(|(i, h)| (*h, i)).collect(); + let hash_to_idx: HashMap = commit_pairs + .iter() + .enumerate() + .map(|(i, (hash, _))| (*hash, i)) + .collect(); - let graph_commits = commits + let graph_commits = commit_pairs .into_iter() - .enumerate() - .map(|(i, c)| { + .map(|(hash, c)| { let parent_indices = c .parents .iter() .filter_map(|p| hash_to_idx.get(p).copied()) .collect(); GraphCommit { - hash: hashes[i], + hash, message: c.message, author: c.author, timestamp: c.timestamp, From c701296abeca4e89cb65c4e9da2532b67909a249 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:15:45 +0700 Subject: [PATCH 3/7] fix(octopus-merge): limit total parents to 255 --- src/command/octopus_merge.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/command/octopus_merge.rs b/src/command/octopus_merge.rs index aab9c03..88dc442 100644 --- a/src/command/octopus_merge.rs +++ b/src/command/octopus_merge.rs @@ -32,6 +32,14 @@ impl Command for OctopusMerge { )); } + let total_parents = 1 + self.branch_names.len(); + if total_parents > 255 { + return Err(VctrlError::Other(format!( + "too many parents: {} (max 255)", + total_parents + ))); + } + let head_hash = refs .head()? .ok_or_else(|| VctrlError::Other("no HEAD".into()))?; From 87b233326334ff0a1bc9a760fdf263087b3c9f8c Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:15:45 +0700 Subject: [PATCH 4/7] fix(rebase): limit number of commits to rebase --- src/command/rebase.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/command/rebase.rs b/src/command/rebase.rs index c07e633..4666ef8 100644 --- a/src/command/rebase.rs +++ b/src/command/rebase.rs @@ -10,6 +10,8 @@ use crate::merge::{ConflictResolver, ThreeWayMerge}; use crate::storage::traits::{ObjectStore, ObjectStoreExt, RefStore}; use std::collections::HashSet; +const MAX_REBASE_COMMITS: usize = 10_000; + pub struct Rebase { pub upstream: Hash, pub onto: Hash, @@ -46,6 +48,13 @@ impl Command for Rebase { let commit = store.get_commit(&h)?; current = commit.parents.first().copied(); to_rebase.push(commit); + + if to_rebase.len() > MAX_REBASE_COMMITS { + return Err(VctrlError::Other(format!( + "too many commits to rebase (limit {})", + MAX_REBASE_COMMITS + ))); + } } to_rebase.reverse(); From b497b7b483fc680237c37d8dfe056aca807a9142 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:15:45 +0700 Subject: [PATCH 5/7] fix(stash): use timestamp for stash ref and sort --- src/command/stash.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/command/stash.rs b/src/command/stash.rs index 651a643..ffd3b97 100644 --- a/src/command/stash.rs +++ b/src/command/stash.rs @@ -38,9 +38,10 @@ impl Command for StashPush { let hash = self.hasher.hash_commit_encoded(&buf); store.put(&hash, &Object::Commit(Box::new(commit)))?; - let existing = refs.list_refs("refs/stash/")?; - let next_index = existing.len(); - let ref_name = format!("refs/stash/{}", next_index); + let nanos = chrono::Utc::now() + .timestamp_nanos_opt() + .ok_or_else(|| VctrlError::Other("invalid system clock".into()))?; + let ref_name = format!("refs/stash/{}", nanos); refs.set_ref(&ref_name, &hash)?; Ok(hash) } @@ -56,20 +57,15 @@ impl Command for StashPop { store: &mut dyn ObjectStore, refs: &mut dyn RefStore, ) -> Result, VctrlError> { - let stash_refs = refs.list_refs("refs/stash/")?; + let mut stash_refs = refs.list_refs("refs/stash/")?; if stash_refs.is_empty() { return Ok(None); } - let mut indices: Vec = stash_refs - .iter() - .filter_map(|r| r.trim_start_matches("refs/stash/").parse::().ok()) - .collect(); - indices.sort(); - let last = indices.last().unwrap(); - let ref_name = format!("refs/stash/{}", last); - let commit_hash = refs.get_ref(&ref_name)?.unwrap(); + stash_refs.sort(); + let last = stash_refs.last().unwrap().clone(); + let commit_hash = refs.get_ref(&last)?.unwrap(); let commit = store.get_commit(&commit_hash)?; - refs.delete_ref(&ref_name)?; + refs.delete_ref(&last)?; Ok(Some(commit.tree)) } } From 8157ccadbe926506c0c2fd53f833202445ae866f Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:15:45 +0700 Subject: [PATCH 6/7] refactor(revwalk): return hash with commit in iterator --- src/revwalk.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/revwalk.rs b/src/revwalk.rs index b00a3d9..8b33d4a 100644 --- a/src/revwalk.rs +++ b/src/revwalk.rs @@ -59,7 +59,7 @@ impl<'a> RevWalk<'a> { } impl<'a> Iterator for RevWalk<'a> { - type Item = Result; + type Item = Result<(Hash, Commit), VctrlError>; fn next(&mut self) -> Option { loop { @@ -72,7 +72,7 @@ impl<'a> Iterator for RevWalk<'a> { return Some(Err(e)); } } - return Some(Ok(*commit)); + return Some(Ok((hash, *commit))); } Ok(_) => continue, Err(e) => return Some(Err(e)), From f00699b1413e528e646ada6ba725f1dc6fdcd941 Mon Sep 17 00:00:00 2001 From: mroczect Date: Wed, 5 Aug 2026 20:15:45 +0700 Subject: [PATCH 7/7] feat(storage): implement object deletion in file store --- src/storage/file_store.rs | 57 ++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/src/storage/file_store.rs b/src/storage/file_store.rs index 7752925..9dcb63b 100644 --- a/src/storage/file_store.rs +++ b/src/storage/file_store.rs @@ -5,7 +5,7 @@ use crate::domain::object::Object; use crate::error::VctrlError; use crate::storage::traits::{ObjectStore, RefStore}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; use std::io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; @@ -20,6 +20,7 @@ const REC_TAG: u8 = 0x04; const REC_SET_REF: u8 = 0x10; const REC_DEL_REF: u8 = 0x11; const REC_SET_HEAD: u8 = 0x12; +const REC_DEL_OBJECT: u8 = 0x20; struct ObjectInfo { rec_type: u8, @@ -32,6 +33,7 @@ pub struct FileStore { objects: HashMap, refs: HashMap, head: Option, + deleted: HashSet, encoder: BinaryEncoder, decoder: BinaryDecoder, writer: Option>, @@ -45,6 +47,7 @@ impl FileStore { objects: HashMap::new(), refs: HashMap::new(), head: None, + deleted: HashSet::new(), encoder: BinaryEncoder, decoder: BinaryDecoder, writer: None, @@ -77,7 +80,9 @@ impl FileStore { .map_err(VctrlError::Io)?; self.writer = Some(BufWriter::new(file)); } - Ok(self.writer.as_mut().unwrap()) + self.writer + .as_mut() + .ok_or_else(|| VctrlError::Backend("no writer".into())) } fn load(&mut self) -> Result<(), VctrlError> { @@ -96,6 +101,7 @@ impl FileStore { version ))); } + let mut deleted_hashes = HashSet::new(); loop { let rec_type = match file.read_u8() { Ok(t) => t, @@ -111,14 +117,16 @@ impl FileStore { let offset = file.stream_position().map_err(VctrlError::Io)?; file.seek(SeekFrom::Current(length as i64)) .map_err(VctrlError::Io)?; - self.objects.insert( - hash, - ObjectInfo { - rec_type, - offset, - length, - }, - ); + if !deleted_hashes.contains(&hash) { + self.objects.insert( + hash, + ObjectInfo { + rec_type, + offset, + length, + }, + ); + } } REC_SET_REF => { let name_len = file.read_u16::().map_err(VctrlError::Io)?; @@ -146,6 +154,11 @@ impl FileStore { String::from_utf8(target).map_err(|e| VctrlError::Other(e.to_string()))?; self.head = Some(target); } + REC_DEL_OBJECT => { + let mut h = [0u8; 64]; + file.read_exact(&mut h).map_err(VctrlError::Io)?; + deleted_hashes.insert(Hash::from_bytes(h)); + } _ => { return Err(VctrlError::Other(format!( "unknown record type {}", @@ -154,6 +167,10 @@ impl FileStore { } } } + for hash in &deleted_hashes { + self.objects.remove(hash); + } + self.deleted = deleted_hashes; Ok(()) } @@ -203,6 +220,7 @@ impl ObjectStore for FileStore { if self.objects.contains_key(hash) { return Ok(()); } + self.deleted.remove(hash); let (rec_type, data) = self.encode_object(obj)?; let writer = self.ensure_writer()?; writer.write_u8(rec_type).map_err(VctrlError::Io)?; @@ -226,6 +244,9 @@ impl ObjectStore for FileStore { } fn get(&self, hash: &Hash) -> Result, VctrlError> { + if self.deleted.contains(hash) { + return Ok(None); + } match self.objects.get(hash) { Some(info) => { let mut file = File::open(&self.path).map_err(VctrlError::Io)?; @@ -241,13 +262,25 @@ impl ObjectStore for FileStore { } fn exists(&self, hash: &Hash) -> Result { - Ok(self.objects.contains_key(hash)) + Ok(self.objects.contains_key(hash) && !self.deleted.contains(hash)) } + fn all_hashes(&self) -> Result, VctrlError> { - Ok(self.objects.keys().copied().collect()) + Ok(self + .objects + .keys() + .filter(|h| !self.deleted.contains(h)) + .copied() + .collect()) } + fn remove(&mut self, hash: &Hash) -> Result<(), VctrlError> { if self.objects.remove(hash).is_some() { + self.deleted.insert(*hash); + let writer = self.ensure_writer()?; + writer.write_u8(REC_DEL_OBJECT).map_err(VctrlError::Io)?; + writer.write_all(hash.as_bytes()).map_err(VctrlError::Io)?; + writer.flush().map_err(VctrlError::Io)?; Ok(()) } else { Err(VctrlError::NotFound(format!("object '{}' not found", hash)))