refactor(server): replace custom remote clients with macro-based implementation

- Replaced manual remote client functions with remote_client! macro for archive, blame, branch, commit, and diff services
- Simplified remote client creation logic using declarative macro approach
- Maintained same functionality while reducing code duplication across services

security(bare): enhance path traversal protection with comprehensive validation

- Added early relative_path validation to prevent path traversal attacks
- Implemented unified path validation to avoid TOCTOU race conditions
- Enhanced canonicalization checks for both existing and non-existent paths
- Added detailed logging for path traversal detection attempts

feat(cache): migrate from CLruCache to Moka with TTL and invalidation support

- Replaced clru dependency with moka for improved caching capabilities
- Added 300-second time-to-live for cache entries
- Implemented repository-specific cache invalidation mechanism
- Enhanced cache operations with thread-safe async support

refactor(commit): improve security validation for commit operations

- Added ref name validation to prevent command injection in cherry_pick_commit
- Implemented revision validation for commit selectors
- Added comprehensive input validation for create_commit parameters
- Enhanced file path validation to prevent traversal
This commit is contained in:
zhenyi
2026-06-08 09:43:57 +08:00
parent 8c95eb230d
commit d243dce027
60 changed files with 1746 additions and 561 deletions
Generated
+44 -1
View File
@@ -289,6 +289,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -659,11 +668,11 @@ name = "gitks"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"clru",
"dotenvy", "dotenvy",
"duct", "duct",
"gix", "gix",
"gix-archive", "gix-archive",
"moka",
"prost", "prost",
"prost-types", "prost-types",
"ractor", "ractor",
@@ -1956,6 +1965,23 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moka"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]] [[package]]
name = "multimap" name = "multimap"
version = "0.10.1" version = "0.10.1"
@@ -2727,6 +2753,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.46" version = "0.4.46"
@@ -3103,6 +3135,17 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "uuid"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
+1 -1
View File
@@ -16,7 +16,7 @@ documentation = ""
path = "lib.rs" path = "lib.rs"
name = "gitks" name = "gitks"
[dependencies] [dependencies]
clru = "0.6" moka = { version = "0.12", default-features = false, features = ["sync"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
gix = { version = "0.84", default-features = false, features = ["serde", "blame", "sha256", "sha1", "tracing", "merge", "max-performance-safe", "revision"] } gix = { version = "0.84", default-features = false, features = ["serde", "blame", "sha256", "sha1", "tracing", "merge", "max-performance-safe", "revision"] }
gix-archive = { version = "0.33", features = ["sha256","sha1","document-features"] } gix-archive = { version = "0.33", features = ["sha256","sha1","document-features"] }
+101 -36
View File
@@ -1,9 +1,11 @@
use std::collections::HashMap; use crate::actor::message::{
GitNodeMessage, NodeHealth, ROLE_PRIMARY, ROLE_REPLICA, RefUpdateEvent, RouteDecision,
};
use crate::server::GitksService;
use async_trait::async_trait; use async_trait::async_trait;
use ractor::pg; use ractor::pg;
use ractor::{Actor, ActorProcessingErr, ActorRef, SupervisionEvent}; use ractor::{Actor, ActorProcessingErr, ActorRef, SupervisionEvent};
use crate::actor::message::{GitNodeMessage, NodeHealth, RefUpdateEvent, RouteDecision, ROLE_PRIMARY, ROLE_REPLICA}; use std::collections::HashMap;
use crate::server::GitksService;
#[derive(Clone)] #[derive(Clone)]
pub struct GitNodeActor { pub struct GitNodeActor {
@@ -50,7 +52,11 @@ impl Actor for GitNodeActor {
) -> Result<Self::State, ActorProcessingErr> { ) -> Result<Self::State, ActorProcessingErr> {
let actor_name = format!("git_node_{}", args.storage_name); let actor_name = format!("git_node_{}", args.storage_name);
pg::join("gitks_nodes".to_string(), vec![myself.get_cell()]); pg::join("gitks_nodes".to_string(), vec![myself.get_cell()]);
pg::join_scoped(args.storage_name.clone(), "node".to_string(), vec![myself.get_cell()]); pg::join_scoped(
args.storage_name.clone(),
"node".to_string(),
vec![myself.get_cell()],
);
tracing::info!(storage_name = %args.storage_name, actor_name = %actor_name, grpc_addr = %args.grpc_addr, "GitNodeActor started"); tracing::info!(storage_name = %args.storage_name, actor_name = %actor_name, grpc_addr = %args.grpc_addr, "GitNodeActor started");
Ok(GitNodeState { Ok(GitNodeState {
storage_name: args.storage_name, storage_name: args.storage_name,
@@ -90,43 +96,60 @@ impl Actor for GitNodeActor {
} }
GitNodeMessage::RefUpdated(event) => { GitNodeMessage::RefUpdated(event) => {
if let Some(entry) = state.repos.get(&event.relative_path) { if let Some(entry) = state.repos.get(&event.relative_path)
if entry.role == ROLE_REPLICA { && entry.role == ROLE_REPLICA
{
let local_path = self.service.repo_prefix.join(&event.relative_path); let local_path = self.service.repo_prefix.join(&event.relative_path);
crate::actor::sync::sync_from_primary(event, local_path).await; crate::actor::sync::sync_from_primary(event, local_path).await;
} }
} }
}
GitNodeMessage::FindPrimary(header, reply) => { GitNodeMessage::FindPrimary(header, reply) => {
let entry = state.repos.get(&header.relative_path); let entry = state.repos.get(&header.relative_path);
let is_primary = entry.is_some_and(|e| e.role == ROLE_PRIMARY); let is_primary = entry.is_some_and(|e| e.role == ROLE_PRIMARY);
reply.send(build_decision(state, &header, is_primary, entry.map(|e| e.role.as_str()))).ok(); reply
.send(build_decision(
state,
&header,
is_primary,
entry.map(|e| e.role.as_str()),
))
.ok();
} }
GitNodeMessage::FindReplica(header, reply) => { GitNodeMessage::FindReplica(header, reply) => {
let entry = state.repos.get(&header.relative_path); let entry = state.repos.get(&header.relative_path);
let has = entry.is_some(); let has = entry.is_some();
reply.send(build_decision(state, &header, has, entry.map(|e| e.role.as_str()))).ok(); reply
.send(build_decision(
state,
&header,
has,
entry.map(|e| e.role.as_str()),
))
.ok();
} }
GitNodeMessage::ListRepositoryPaths(reply) => { GitNodeMessage::ListRepositoryPaths(reply) => {
let paths: Vec<String> = state.repos.keys().cloned().collect(); let paths: Vec<String> = state.repos.keys().cloned().collect();
reply.send(paths.join("\n")).ok(); reply.send(paths.join("\n")).ok();
} }
GitNodeMessage::RepositoryExists(header, reply) => { GitNodeMessage::RepositoryExists(header, reply) => {
reply.send(state.repos.contains_key(&header.relative_path)).ok(); reply
.send(state.repos.contains_key(&header.relative_path))
.ok();
} }
GitNodeMessage::GetNodeHealth(reply) => { GitNodeMessage::GetNodeHealth(reply) => {
reply.send(NodeHealth { reply
.send(NodeHealth {
storage_name: state.storage_name.clone(), storage_name: state.storage_name.clone(),
repo_count: state.repos.len() as u64, repo_count: state.repos.len() as u64,
healthy: true, healthy: true,
version: self.version.clone(), version: self.version.clone(),
}).ok(); })
.ok();
} }
} }
Ok(()) Ok(())
@@ -139,14 +162,18 @@ impl Actor for GitNodeActor {
_state: &mut Self::State, _state: &mut Self::State,
) -> Result<(), ActorProcessingErr> { ) -> Result<(), ActorProcessingErr> {
match evt { match evt {
SupervisionEvent::ActorStarted(who) => tracing::debug!(actor = ?who.get_id(), "child started"), SupervisionEvent::ActorStarted(who) => {
tracing::debug!(actor = ?who.get_id(), "child started")
}
SupervisionEvent::ActorTerminated(who, _, reason) => { SupervisionEvent::ActorTerminated(who, _, reason) => {
tracing::warn!(actor = ?who.get_id(), reason = ?reason, "child terminated") tracing::warn!(actor = ?who.get_id(), reason = ?reason, "child terminated")
} }
SupervisionEvent::ActorFailed(who, panic_msg) => { SupervisionEvent::ActorFailed(who, panic_msg) => {
tracing::error!(actor = ?who.get_id(), msg = %panic_msg, "child panicked") tracing::error!(actor = ?who.get_id(), msg = %panic_msg, "child panicked")
} }
SupervisionEvent::ProcessGroupChanged(group) => tracing::info!(group = ?group, "PG membership changed"), SupervisionEvent::ProcessGroupChanged(group) => {
tracing::info!(group = ?group, "PG membership changed")
}
_ => {} _ => {}
} }
Ok(()) Ok(())
@@ -162,48 +189,83 @@ impl Actor for GitNodeActor {
} }
} }
fn build_decision(state: &GitNodeState, header: &crate::pb::RepositoryHeader, found: bool, role: Option<&str>) -> RouteDecision { fn build_decision(
state: &GitNodeState,
header: &crate::pb::RepositoryHeader,
found: bool,
role: Option<&str>,
) -> RouteDecision {
RouteDecision { RouteDecision {
found, found,
storage_name: if found { state.storage_name.clone() } else { String::new() }, storage_name: if found {
state.storage_name.clone()
} else {
String::new()
},
relative_path: header.relative_path.clone(), relative_path: header.relative_path.clone(),
actor_name: if found { state.actor_name.clone() } else { String::new() }, actor_name: if found {
grpc_addr: if found { state.grpc_addr.clone() } else { String::new() }, state.actor_name.clone()
} else {
String::new()
},
grpc_addr: if found {
state.grpc_addr.clone()
} else {
String::new()
},
role: role.unwrap_or("").to_string(), role: role.unwrap_or("").to_string(),
} }
} }
fn register_repo(myself: &ActorRef<GitNodeMessage>, state: &mut GitNodeState, relative_path: String) { fn register_repo(
myself: &ActorRef<GitNodeMessage>,
state: &mut GitNodeState,
relative_path: String,
) {
if state.repos.contains_key(&relative_path) { if state.repos.contains_key(&relative_path) {
return; return;
} }
let role = if is_path_registered_elsewhere(&state.storage_name, &relative_path) { // Determine role based on cluster state
// For simplicity and correctness, we use a conservative approach:
// If there are other nodes in the cluster, register as replica initially.
// The route_repository logic will determine the actual primary at query time.
let members = ractor::pg::get_members(&"gitks_nodes".to_string());
let my_cell = myself.get_cell();
let other_nodes_exist = members.iter().any(|m| m != &my_cell);
let role = if other_nodes_exist {
// Conservative: assume another node might be primary
// The actual primary will be determined by route_repository query
ROLE_REPLICA.to_string() ROLE_REPLICA.to_string()
} else { } else {
// We're the only node, so we're primary
ROLE_PRIMARY.to_string() ROLE_PRIMARY.to_string()
}; };
let category = extract_category(&relative_path); let category = extract_category(&relative_path);
pg::join_scoped(state.storage_name.clone(), category.to_string(), vec![myself.get_cell()]); pg::join_scoped(
state.repos.insert(relative_path.clone(), RepoEntry { state.storage_name.clone(),
category.to_string(),
vec![myself.get_cell()],
);
state.repos.insert(
relative_path.clone(),
RepoEntry {
role: role.clone(), role: role.clone(),
last_commit: String::new(), last_commit: String::new(),
}); },
);
tracing::info!( tracing::info!(
storage_name = %state.storage_name, storage_name = %state.storage_name,
category = %category, category = %category,
relative_path = %relative_path, relative_path = %relative_path,
actor_name = %state.actor_name, actor_name = %state.actor_name,
role = %role, role = %role,
"repository route registered" "repository route registered (role will be refined at query time)"
); );
} }
fn is_path_registered_elsewhere(_storage_name: &str, _relative_path: &str) -> bool {
false
}
fn extract_category(relative_path: &str) -> &str { fn extract_category(relative_path: &str) -> &str {
relative_path.split('/').next().unwrap_or("root") relative_path.split('/').next().unwrap_or("root")
} }
@@ -217,8 +279,12 @@ pub async fn start_node_actor(
let (actor_ref, handle) = Actor::spawn( let (actor_ref, handle) = Actor::spawn(
Some(format!("git_node_{storage_name}")), Some(format!("git_node_{storage_name}")),
actor, actor,
GitNodeArgs { storage_name, grpc_addr }, GitNodeArgs {
).await?; storage_name,
grpc_addr,
},
)
.await?;
actor_ref.cast(GitNodeMessage::ScanAndRegister).ok(); actor_ref.cast(GitNodeMessage::ScanAndRegister).ok();
Ok((actor_ref, handle)) Ok((actor_ref, handle))
} }
@@ -239,13 +305,12 @@ pub fn list_all_groups() -> Vec<String> {
pg::which_groups() pg::which_groups()
} }
pub fn broadcast_ref_update( pub fn broadcast_ref_update(_node_actor: &ActorRef<GitNodeMessage>, event: RefUpdateEvent) {
_node_actor: &ActorRef<GitNodeMessage>,
event: RefUpdateEvent,
) {
let members = ractor::pg::get_members(&"gitks_nodes".to_string()); let members = ractor::pg::get_members(&"gitks_nodes".to_string());
for member in members { for member in members {
let actor_ref: ActorRef<GitNodeMessage> = member.into(); let actor_ref: ActorRef<GitNodeMessage> = member.into();
actor_ref.cast(GitNodeMessage::RefUpdated(event.clone())).ok(); actor_ref
.cast(GitNodeMessage::RefUpdated(event.clone()))
.ok();
} }
} }
+62 -7
View File
@@ -1,7 +1,7 @@
use crate::pb::RepositoryHeader;
use ractor::RpcReplyPort; use ractor::RpcReplyPort;
use ractor_cluster::BytesConvertable; use ractor_cluster::BytesConvertable;
use ractor_cluster::RactorClusterMessage; use ractor_cluster::RactorClusterMessage;
use crate::pb::RepositoryHeader;
impl BytesConvertable for RepositoryHeader { impl BytesConvertable for RepositoryHeader {
fn into_bytes(self) -> Vec<u8> { fn into_bytes(self) -> Vec<u8> {
@@ -73,7 +73,10 @@ impl BytesConvertable for NodeHealth {
let values = decode_strings(bytes); let values = decode_strings(bytes);
Self { Self {
storage_name: values.first().cloned().unwrap_or_default(), storage_name: values.first().cloned().unwrap_or_default(),
repo_count: values.get(1).and_then(|v| v.parse().ok()).unwrap_or_default(), repo_count: values
.get(1)
.and_then(|v| v.parse().ok())
.unwrap_or_default(),
healthy: values.get(2).is_some_and(|v| v == "1"), healthy: values.get(2).is_some_and(|v| v == "1"),
version: values.get(3).cloned().unwrap_or_default(), version: values.get(3).cloned().unwrap_or_default(),
} }
@@ -156,17 +159,69 @@ fn encode_strings(values: &[String]) -> Vec<u8> {
buf buf
} }
// Maximum allowed length for a single string in the message
const MAX_STRING_LEN: usize = 10 * 1024 * 1024; // 10MB
// Maximum total message size
const MAX_TOTAL_SIZE: usize = 50 * 1024 * 1024; // 50MB
fn decode_strings(bytes: Vec<u8>) -> Vec<String> { fn decode_strings(bytes: Vec<u8>) -> Vec<String> {
let mut values = Vec::new(); let mut values = Vec::new();
let mut offset = 0; let mut offset = 0;
// Check total message size
if bytes.len() > MAX_TOTAL_SIZE {
tracing::warn!(
total = bytes.len(),
max = MAX_TOTAL_SIZE,
"message exceeds maximum size, truncating"
);
return values;
}
while offset + 8 <= bytes.len() { while offset + 8 <= bytes.len() {
let len = u64::from_be_bytes(bytes[offset..offset + 8].try_into().unwrap()) as usize; let len_bytes: [u8; 8] = bytes[offset..offset + 8].try_into().unwrap_or([0u8; 8]);
offset += 8; let len_u64 = u64::from_be_bytes(len_bytes);
if offset + len > bytes.len() {
// Prevent DoS via extremely large length values
if len_u64 > MAX_STRING_LEN as u64 {
tracing::warn!(
offset,
claimed_len = len_u64,
max = MAX_STRING_LEN,
"string length exceeds maximum, stopping decode"
);
break; break;
} }
values.push(String::from_utf8_lossy(&bytes[offset..offset + len]).into_owned());
offset += len; let len = len_u64 as usize;
offset += 8;
// Prevent integer overflow in offset calculation
let end_offset = match offset.checked_add(len) {
Some(end) => end,
None => {
tracing::warn!(
offset,
len,
"integer overflow in offset calculation, stopping decode"
);
break;
}
};
if len == 0 || end_offset > bytes.len() {
// Invalid length — stop decoding, return what we have so far
tracing::warn!(
offset,
claimed_len = len,
total = bytes.len(),
"malformed bytes in decode_strings, stopping early"
);
break;
}
values.push(String::from_utf8_lossy(&bytes[offset..end_offset]).into_owned());
offset = end_offset;
} }
values values
} }
+9 -3
View File
@@ -1,8 +1,14 @@
pub mod message;
pub mod handler; pub mod handler;
pub mod message;
pub mod server; pub mod server;
pub mod sync; pub mod sync;
pub use handler::{GitNodeActor, GitNodeArgs, RepoEntry, start_node_actor, get_cluster_nodes, get_category_members, route_group_for, list_all_groups, broadcast_ref_update}; pub use handler::{
GitNodeActor, GitNodeArgs, RepoEntry, broadcast_ref_update, get_category_members,
get_cluster_nodes, list_all_groups, route_group_for, start_node_actor,
};
pub use message::{
GitNodeMessage, NodeHealth, ROLE_PRIMARY, ROLE_REPLICA, RefUpdateEvent, RepoActorMessage,
RouteDecision,
};
pub use server::init_actor_cluster; pub use server::init_actor_cluster;
pub use message::{GitNodeMessage, NodeHealth, RefUpdateEvent, RepoActorMessage, RouteDecision, ROLE_PRIMARY, ROLE_REPLICA};
+1 -1
View File
@@ -1,7 +1,7 @@
use ractor::ActorRef;
use crate::actor::handler::start_node_actor; use crate::actor::handler::start_node_actor;
use crate::actor::message::GitNodeMessage; use crate::actor::message::GitNodeMessage;
use crate::server::GitksService; use crate::server::GitksService;
use ractor::ActorRef;
pub async fn init_actor_cluster( pub async fn init_actor_cluster(
service: GitksService, service: GitksService,
+63 -28
View File
@@ -1,6 +1,6 @@
use std::path::PathBuf;
use crate::actor::message::RefUpdateEvent; use crate::actor::message::RefUpdateEvent;
use crate::pb::Oid; use crate::pb::Oid;
use std::path::{Path, PathBuf};
pub struct BundleApplicator { pub struct BundleApplicator {
pub repo_path: PathBuf, pub repo_path: PathBuf,
@@ -13,7 +13,13 @@ impl BundleApplicator {
pub fn apply_bundle(&self, data: &[u8]) -> Result<(), String> { pub fn apply_bundle(&self, data: &[u8]) -> Result<(), String> {
let mut child = std::process::Command::new("git") let mut child = std::process::Command::new("git")
.args(["--git-dir", &self.repo_path.to_string_lossy(), "bundle", "unbundle", "-"]) .args([
"--git-dir",
&self.repo_path.to_string_lossy(),
"bundle",
"unbundle",
"-",
])
.stdin(std::process::Stdio::piped()) .stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
@@ -21,9 +27,13 @@ impl BundleApplicator {
.map_err(|e| format!("spawn git bundle unbundle: {e}"))?; .map_err(|e| format!("spawn git bundle unbundle: {e}"))?;
use std::io::Write; use std::io::Write;
if let Some(ref mut stdin) = child.stdin { if let Some(ref mut stdin) = child.stdin {
stdin.write_all(data).map_err(|e| format!("write bundle: {e}"))?; stdin
.write_all(data)
.map_err(|e| format!("write bundle: {e}"))?;
} }
let output = child.wait_with_output().map_err(|e| format!("wait bundle: {e}"))?; let output = child
.wait_with_output()
.map_err(|e| format!("wait bundle: {e}"))?;
if !output.status.success() { if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned()); return Err(String::from_utf8_lossy(&output.stderr).into_owned());
} }
@@ -31,7 +41,7 @@ impl BundleApplicator {
} }
} }
pub fn collect_local_haves(repo_path: &PathBuf) -> Result<Vec<Oid>, String> { pub fn collect_local_haves(repo_path: &Path) -> Result<Vec<Oid>, String> {
let result = std::process::Command::new("git") let result = std::process::Command::new("git")
.args([ .args([
"--git-dir", "--git-dir",
@@ -84,13 +94,13 @@ pub async fn sync_from_primary(event: RefUpdateEvent, local_repo_path: PathBuf)
match tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || {
sync_via_pack_service(&grpc_addr, &relative_path, &repo_for_haves) sync_via_pack_service(&grpc_addr, &relative_path, &repo_for_haves)
}).await { })
.await
{
Ok(Ok(pack_data)) if !pack_data.is_empty() => { Ok(Ok(pack_data)) if !pack_data.is_empty() => {
let pack_len = pack_data.len(); let pack_len = pack_data.len();
let repo = local_repo_path.clone(); let repo = local_repo_path.clone();
match tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || apply_pack_data(&repo, &pack_data)).await {
apply_pack_data(&repo, &pack_data)
}).await {
Ok(Ok(())) => { Ok(Ok(())) => {
update_local_ref(&local_repo_path, &event.ref_name, &event.new_oid); update_local_ref(&local_repo_path, &event.ref_name, &event.new_oid);
tracing::info!( tracing::info!(
@@ -99,27 +109,39 @@ pub async fn sync_from_primary(event: RefUpdateEvent, local_repo_path: PathBuf)
"replica sync done" "replica sync done"
); );
} }
Ok(Err(e)) => tracing::error!(relative_path = %event.relative_path, error = %e, "pack apply failed"), Ok(Err(e)) => {
Err(e) => tracing::error!(relative_path = %event.relative_path, error = %e, "apply task failed"), tracing::error!(relative_path = %event.relative_path, error = %e, "pack apply failed")
}
Err(e) => {
tracing::error!(relative_path = %event.relative_path, error = %e, "apply task failed")
} }
} }
Ok(Ok(_)) => tracing::warn!(relative_path = %event.relative_path, "empty pack data from primary"), }
Ok(Err(e)) => tracing::error!(relative_path = %event.relative_path, error = %e, "pack fetch failed"), Ok(Ok(_)) => {
Err(e) => tracing::error!(relative_path = %event.relative_path, error = %e, "sync task failed"), tracing::warn!(relative_path = %event.relative_path, "empty pack data from primary")
}
Ok(Err(e)) => {
tracing::error!(relative_path = %event.relative_path, error = %e, "pack fetch failed")
}
Err(e) => {
tracing::error!(relative_path = %event.relative_path, error = %e, "sync task failed")
}
} }
} }
fn sync_via_pack_service( fn sync_via_pack_service(
grpc_addr: &str, grpc_addr: &str,
relative_path: &str, relative_path: &str,
local_repo_path: &PathBuf, local_repo_path: &Path,
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, String> {
let haves = collect_local_haves(local_repo_path)?; let haves = collect_local_haves(local_repo_path)?;
let rt = tokio::runtime::Handle::current(); let rt = tokio::runtime::Handle::current();
rt.block_on(async { rt.block_on(async {
use crate::pb::pack_service_client::PackServiceClient; use crate::pb::pack_service_client::PackServiceClient;
use crate::pb::{AdvertiseRefsRequest, PackObjectsOptions, PackObjectsRequest, RepositoryHeader}; use crate::pb::{
AdvertiseRefsRequest, PackObjectsOptions, PackObjectsRequest, RepositoryHeader,
};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
let endpoint = crate::server::remote_endpoint(grpc_addr) let endpoint = crate::server::remote_endpoint(grpc_addr)
@@ -136,20 +158,21 @@ fn sync_via_pack_service(
storage_path: String::new(), storage_path: String::new(),
}; };
let refs_resp = client.advertise_refs(AdvertiseRefsRequest { let refs_resp = client
.advertise_refs(AdvertiseRefsRequest {
repository: Some(header.clone()), repository: Some(header.clone()),
protocol: None, protocol: None,
service: "upload-pack".to_string(), service: "upload-pack".to_string(),
}).await.map_err(|e| format!("AdvertiseRefs: {e}"))?; })
.await
.map_err(|e| format!("AdvertiseRefs: {e}"))?;
let refs = refs_resp.into_inner().references; let refs = refs_resp.into_inner().references;
if refs.is_empty() { if refs.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let wants: Vec<Oid> = refs.iter() let wants: Vec<Oid> = refs.iter().filter_map(|r| r.target_oid.clone()).collect();
.filter_map(|r| r.target_oid.clone())
.collect();
let want_count = wants.len(); let want_count = wants.len();
let have_count = haves.len(); let have_count = haves.len();
@@ -178,7 +201,9 @@ fn sync_via_pack_service(
options: Some(options), options: Some(options),
}; };
let resp = client.pack_objects(req).await let resp = client
.pack_objects(req)
.await
.map_err(|e| format!("PackObjects: {e}"))?; .map_err(|e| format!("PackObjects: {e}"))?;
let mut stream = resp.into_inner(); let mut stream = resp.into_inner();
@@ -200,21 +225,31 @@ fn sync_via_pack_service(
}) })
} }
fn apply_pack_data(repo_path: &PathBuf, pack_data: &[u8]) -> Result<(), String> { fn apply_pack_data(repo_path: &Path, pack_data: &[u8]) -> Result<(), String> {
let applicator = BundleApplicator::new(repo_path.clone()); let applicator = BundleApplicator::new(repo_path.to_path_buf());
applicator.apply_bundle(pack_data) applicator.apply_bundle(pack_data)
} }
fn update_local_ref(repo_path: &PathBuf, ref_name: &str, new_oid: &str) { fn update_local_ref(repo_path: &Path, ref_name: &str, new_oid: &str) {
if ref_name.is_empty() || new_oid.is_empty() { if ref_name.is_empty() || new_oid.is_empty() {
return; return;
} }
match std::process::Command::new("git") match std::process::Command::new("git")
.args(["--git-dir", &repo_path.to_string_lossy(), "update-ref", ref_name, new_oid]) .args([
"--git-dir",
&repo_path.to_string_lossy(),
"update-ref",
ref_name,
new_oid,
])
.output() .output()
{ {
Ok(o) if o.status.success() => tracing::info!(ref_name = %ref_name, new_oid = %new_oid, "ref updated"), Ok(o) if o.status.success() => {
Ok(o) => tracing::error!(ref_name = %ref_name, error = %String::from_utf8_lossy(&o.stderr), "update-ref failed"), tracing::info!(ref_name = %ref_name, new_oid = %new_oid, "ref updated")
}
Ok(o) => {
tracing::error!(ref_name = %ref_name, error = %String::from_utf8_lossy(&o.stderr), "update-ref failed")
}
Err(e) => tracing::error!(ref_name = %ref_name, error = %e, "update-ref spawn failed"), Err(e) => tracing::error!(ref_name = %ref_name, error = %e, "update-ref spawn failed"),
} }
} }
+9 -3
View File
@@ -20,13 +20,19 @@ impl GitBare {
let (tx, rx) = tokio::sync::mpsc::channel(16); let (tx, rx) = tokio::sync::mpsc::channel(16);
// Spawn the blocking git subprocess in a dedicated thread // Validate revision before spawning (cannot use ? inside spawn_blocking closure)
tokio::task::spawn_blocking(move || {
let revision = match request.treeish.and_then(|s| s.selector) { let revision = match request.treeish.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex, Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision, Some(object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)
.map_err(|e| tonic::Status::invalid_argument(e.to_string()))?;
name.revision
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
// Spawn the blocking git subprocess in a dedicated thread
tokio::task::spawn_blocking(move || {
let options = request.options.unwrap_or_default(); let options = request.options.unwrap_or_default();
let format = archive_options::Format::try_from(options.format) let format = archive_options::Format::try_from(options.format)
.unwrap_or(archive_options::Format::ArchiveFormatTar); .unwrap_or(archive_options::Format::ArchiveFormatTar);
+4 -1
View File
@@ -14,7 +14,10 @@ impl GitBare {
) -> GitResult<ListArchiveEntriesResponse> { ) -> GitResult<ListArchiveEntriesResponse> {
let revision = match request.treeish.and_then(|s| s.selector) { let revision = match request.treeish.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex, Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision, Some(object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
let mut args = vec!["ls-tree".to_string(), "-r".into(), "-l".into(), revision]; let mut args = vec!["ls-tree".to_string(), "-r".into(), "-l".into(), revision];
+50 -3
View File
@@ -27,6 +27,11 @@ impl GitBare {
let storage_name = header.storage_name.trim(); let storage_name = header.storage_name.trim();
let _ = storage_name; // reserved for future sharding logic let _ = storage_name; // reserved for future sharding logic
// Validate relative_path early to prevent path traversal
if !relative_path.is_empty() {
crate::sanitize::validate_relative_path(relative_path)?;
}
// Build base path: storage_path if given, else relative_path alone // Build base path: storage_path if given, else relative_path alone
let base = if !storage_path.is_empty() { let base = if !storage_path.is_empty() {
let p = Path::new(storage_path); let p = Path::new(storage_path);
@@ -46,13 +51,55 @@ impl GitBare {
let bare_dir = if !relative_path.is_empty() && !storage_path.is_empty() { let bare_dir = if !relative_path.is_empty() && !storage_path.is_empty() {
let candidate = base.join(relative_path); let candidate = base.join(relative_path);
let canonical = candidate // Canonicalize base (parent dir likely exists) for a reliable traversal check.
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
let base_canon = base.canonicalize().unwrap_or_else(|_| base.clone()); let base_canon = base.canonicalize().unwrap_or_else(|_| base.clone());
// Unified path validation to avoid TOCTOU race condition
let canonical = match candidate.canonicalize() {
Ok(canon) => {
// Path exists and was canonicalized successfully
canon
}
Err(_) => {
// Path doesn't exist yet — validate via parent directory
// This avoids TOCTOU by not having separate code paths
let parent = candidate.parent().unwrap_or(&base);
let filename = candidate.file_name().ok_or_else(|| {
GitError::InvalidArgument("invalid path: missing filename".into())
})?;
// Canonicalize parent (which should exist)
let parent_canon = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
// Construct the full path and verify it's under base
let constructed = parent_canon.join(filename);
// String-level check as fallback for non-existent paths
let constructed_str = constructed.to_string_lossy();
let base_str = base_canon.to_string_lossy();
if !constructed_str.starts_with(&*base_str) {
tracing::warn!(
relative_path = %relative_path,
base = %base_canon.display(),
"path traversal attempt detected (parent check)"
);
return Err(GitError::InvalidArgument(format!(
"path traversal detected: {relative_path} escapes storage root"
)));
}
constructed
}
};
// Final verification: canonical path must be under base
if !canonical.starts_with(&base_canon) { if !canonical.starts_with(&base_canon) {
tracing::warn!( tracing::warn!(
relative_path = %relative_path, relative_path = %relative_path,
canonical = %canonical.display(),
base = %base_canon.display(), base = %base_canon.display(),
"path traversal attempt detected" "path traversal attempt detected"
); );
+4 -1
View File
@@ -6,7 +6,10 @@ impl GitBare {
pub fn blame(&self, request: BlameRequest) -> GitResult<BlameResponse> { pub fn blame(&self, request: BlameRequest) -> GitResult<BlameResponse> {
let revision = match request.revision.and_then(|s| s.selector) { let revision = match request.revision.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(), Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(),
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision.clone(), Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision.clone()
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
tracing::info!( tracing::info!(
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::tree;
impl GitBare { impl GitBare {
pub fn get_blob(&self, request: GetBlobRequest) -> GitResult<Blob> { pub fn get_blob(&self, request: GetBlobRequest) -> GitResult<Blob> {
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let revision = tree::resolve_revision(&request.revision); let revision = tree::resolve_revision(&request.revision)?;
let (blob, mode, path) = if let Some(oid) = request.oid.as_ref() { let (blob, mode, path) = if let Some(oid) = request.oid.as_ref() {
let id = gix::hash::ObjectId::from_hex(oid.hex.as_bytes()) let id = gix::hash::ObjectId::from_hex(oid.hex.as_bytes())
.map_err(|e| GitError::InvalidOid(e.to_string()))?; .map_err(|e| GitError::InvalidOid(e.to_string()))?;
+5 -1
View File
@@ -6,9 +6,13 @@ use crate::pb::{Branch, CreateBranchRequest, GetBranchRequest, object_selector};
impl GitBare { impl GitBare {
pub fn create_branch(&self, request: CreateBranchRequest) -> GitResult<Branch> { pub fn create_branch(&self, request: CreateBranchRequest) -> GitResult<Branch> {
crate::sanitize::validate_ref_name(&request.name)?;
let revision = match request.start_point.and_then(|s| s.selector) { let revision = match request.start_point.and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex, Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision, Some(object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
let mut args = vec!["branch".to_string()]; let mut args = vec!["branch".to_string()];
+1
View File
@@ -6,6 +6,7 @@ use crate::pb::DeleteBranchRequest;
impl GitBare { impl GitBare {
pub fn delete_branch(&self, request: DeleteBranchRequest) -> GitResult<()> { pub fn delete_branch(&self, request: DeleteBranchRequest) -> GitResult<()> {
crate::sanitize::validate_ref_name(&request.name)?;
let flag = if request.force { "-D" } else { "-d" }; let flag = if request.force { "-D" } else { "-d" };
let output = Command::new("git") let output = Command::new("git")
.arg("--git-dir") .arg("--git-dir")
+2
View File
@@ -6,6 +6,8 @@ use crate::pb::{Branch, GetBranchRequest, RenameBranchRequest};
impl GitBare { impl GitBare {
pub fn rename_branch(&self, request: RenameBranchRequest) -> GitResult<Branch> { pub fn rename_branch(&self, request: RenameBranchRequest) -> GitResult<Branch> {
crate::sanitize::validate_ref_name(&request.old_name)?;
crate::sanitize::validate_ref_name(&request.new_name)?;
let output = Command::new("git") let output = Command::new("git")
.arg("--git-dir") .arg("--git-dir")
.arg(&self.bare_dir) .arg(&self.bare_dir)
+1
View File
@@ -4,6 +4,7 @@ use crate::pb::{Branch, GetBranchRequest, SetBranchUpstreamRequest};
impl GitBare { impl GitBare {
pub fn set_branch_upstream(&self, request: SetBranchUpstreamRequest) -> GitResult<Branch> { pub fn set_branch_upstream(&self, request: SetBranchUpstreamRequest) -> GitResult<Branch> {
crate::sanitize::validate_ref_name(&request.name)?;
if let Some(upstream) = request.upstream { if let Some(upstream) = request.upstream {
let tracking = format!("{}/{}", upstream.remote_name, upstream.remote_branch_name); let tracking = format!("{}/{}", upstream.remote_name, upstream.remote_branch_name);
let result = duct::cmd( let result = duct::cmd(
+1
View File
@@ -7,6 +7,7 @@ use crate::pb::{Branch, GetBranchRequest, UpdateBranchTargetRequest};
impl GitBare { impl GitBare {
pub fn update_branch_target(&self, request: UpdateBranchTargetRequest) -> GitResult<Branch> { pub fn update_branch_target(&self, request: UpdateBranchTargetRequest) -> GitResult<Branch> {
crate::sanitize::validate_ref_name(&request.name)?;
let new_oid = request let new_oid = request
.new_oid .new_oid
.as_ref() .as_ref()
+5 -1
View File
@@ -9,9 +9,13 @@ impl GitBare {
request: CherryPickCommitRequest, request: CherryPickCommitRequest,
) -> GitResult<CreateCommitResponse> { ) -> GitResult<CreateCommitResponse> {
let target_branch = request.branch.clone(); let target_branch = request.branch.clone();
crate::sanitize::validate_ref_name(&target_branch)?;
let cp_revision = match request.commit.and_then(|s| s.selector) { let cp_revision = match request.commit.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => return Err(GitError::InvalidArgument("commit is required".into())), None => return Err(GitError::InvalidArgument("commit is required".into())),
}; };
+4 -11
View File
@@ -2,7 +2,8 @@ use crate::bare::GitBare;
use crate::commit::list_commits::read_commit_from_repo; use crate::commit::list_commits::read_commit_from_repo;
use crate::diff::get_diff_stats::diff_stats_for_range; use crate::diff::get_diff_stats::diff_stats_for_range;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::{CommitStats, CompareCommitsRequest, CompareCommitsResponse, object_selector}; use crate::pb::{CommitStats, CompareCommitsRequest, CompareCommitsResponse};
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn compare_commits( pub fn compare_commits(
@@ -10,16 +11,8 @@ impl GitBare {
request: CompareCommitsRequest, request: CompareCommitsRequest,
) -> GitResult<CompareCommitsResponse> { ) -> GitResult<CompareCommitsResponse> {
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let base = match request.base.clone().and_then(|s| s.selector) { let base = resolve_revision!(request.base.clone());
Some(object_selector::Selector::Oid(oid)) => oid.hex, let head = resolve_revision!(request.head.clone());
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let head = match request.head.clone().and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let base_id = repo.rev_parse_single(base.as_str())?; let base_id = repo.rev_parse_single(base.as_str())?;
let head_id = repo.rev_parse_single(head.as_str())?; let head_id = repo.rev_parse_single(head.as_str())?;
+33 -10
View File
@@ -8,6 +8,19 @@ use crate::pb::{
impl GitBare { impl GitBare {
pub fn create_commit(&self, request: CreateCommitRequest) -> GitResult<CreateCommitResponse> { pub fn create_commit(&self, request: CreateCommitRequest) -> GitResult<CreateCommitResponse> {
// Validate branch name to prevent command injection
crate::sanitize::validate_ref_name(&request.branch)?;
// Validate start_revision if provided
if let Some(rev) = request.start_revision.as_ref() {
match rev.selector.as_ref() {
Some(object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
}
Some(object_selector::Selector::Oid(_)) => {} // OID is always safe
None => {} // will use branch name, already validated
}
}
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let branch = request.branch.clone(); let branch = request.branch.clone();
tracing::debug!( tracing::debug!(
@@ -21,15 +34,15 @@ impl GitBare {
Some(object_selector::Selector::Revision(name)) => name.revision, Some(object_selector::Selector::Revision(name)) => name.revision,
None => request.branch.clone(), None => request.branch.clone(),
}; };
let parent_id = repo let parent_id = match repo.rev_parse_single(start_rev.as_str()) {
.rev_parse_single(start_rev.as_str()) Ok(id) => Some(id.to_string()),
.ok() Err(_) => None, // branch/revision does not exist yet — will create initial commit
.map(|id| id.to_string()); };
let current_branch_tip = repo let current_branch_tip =
.find_reference(format!("refs/heads/{}", request.branch).as_str()) match repo.find_reference(format!("refs/heads/{}", request.branch).as_str()) {
.ok() Ok(mut r) => r.peel_to_id().ok().map(|id| id.to_string()),
.and_then(|mut r| r.peel_to_id().ok()) Err(_) => None, // branch does not exist yet
.map(|id| id.to_string()); };
let tree_id = if request.actions.is_empty() { let tree_id = if request.actions.is_empty() {
let Some(parent) = parent_id.as_ref() else { let Some(parent) = parent_id.as_ref() else {
@@ -91,9 +104,11 @@ impl GitBare {
parent_id: Option<&str>, parent_id: Option<&str>,
actions: &[crate::pb::CreateCommitAction], actions: &[crate::pb::CreateCommitAction],
) -> GitResult<String> { ) -> GitResult<String> {
// Use system temp directory instead of bare_dir to avoid clutter
// and ensure proper cleanup even if process crashes
let tmp_index = tempfile::Builder::new() let tmp_index = tempfile::Builder::new()
.prefix("gitks-index-") .prefix("gitks-index-")
.tempfile_in(&self.bare_dir) .tempfile()
.map_err(GitError::Io)?; .map_err(GitError::Io)?;
let tmp_index_path = tmp_index.path().to_string_lossy().into_owned(); let tmp_index_path = tmp_index.path().to_string_lossy().into_owned();
@@ -140,6 +155,14 @@ impl GitBare {
index_path: &str, index_path: &str,
action: &crate::pb::CreateCommitAction, action: &crate::pb::CreateCommitAction,
) -> GitResult<()> { ) -> GitResult<()> {
// Validate file paths to prevent command injection / traversal
if !action.file_path.is_empty() {
crate::sanitize::validate_file_path(&action.file_path)?;
}
if !action.previous_path.is_empty() {
crate::sanitize::validate_file_path(&action.previous_path)?;
}
let action_type = create_commit_action::Action::try_from(action.action) let action_type = create_commit_action::Action::try_from(action.action)
.unwrap_or(create_commit_action::Action::CreateCommitActionUnspecified); .unwrap_or(create_commit_action::Action::CreateCommitActionUnspecified);
match action_type { match action_type {
+3 -6
View File
@@ -1,15 +1,12 @@
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::{Commit, GetCommitRequest, object_selector}; use crate::pb::{Commit, GetCommitRequest};
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn get_commit(&self, request: GetCommitRequest) -> GitResult<Commit> { pub fn get_commit(&self, request: GetCommitRequest) -> GitResult<Commit> {
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let revision = match request.revision.and_then(|s| s.selector) { let revision = resolve_revision!(request.revision);
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let id = repo.rev_parse_single(revision.as_str())?; let id = repo.rev_parse_single(revision.as_str())?;
let commit = id let commit = id
.object()? .object()?
+3 -6
View File
@@ -1,14 +1,11 @@
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::{Commit, ListCommitsRequest, ListCommitsResponse, object_selector}; use crate::pb::{Commit, ListCommitsRequest, ListCommitsResponse};
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn list_commits(&self, request: ListCommitsRequest) -> GitResult<ListCommitsResponse> { pub fn list_commits(&self, request: ListCommitsRequest) -> GitResult<ListCommitsResponse> {
let revision = match request.revision.clone().and_then(|s| s.selector) { let revision = resolve_revision!(request.revision.clone());
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let base_args = build_rev_list_args(self, &request, &revision); let base_args = build_rev_list_args(self, &request, &revision);
+5 -1
View File
@@ -6,9 +6,13 @@ use crate::pb::{CreateCommitResponse, GetCommitRequest, RevertCommitRequest};
impl GitBare { impl GitBare {
pub fn revert_commit(&self, request: RevertCommitRequest) -> GitResult<CreateCommitResponse> { pub fn revert_commit(&self, request: RevertCommitRequest) -> GitResult<CreateCommitResponse> {
let target_branch = request.branch.clone(); let target_branch = request.branch.clone();
crate::sanitize::validate_ref_name(&target_branch)?;
let revert_revision = match request.commit.and_then(|s| s.selector) { let revert_revision = match request.commit.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => return Err(GitError::InvalidArgument("commit is required".into())), None => return Err(GitError::InvalidArgument("commit is required".into())),
}; };
+2 -5
View File
@@ -1,14 +1,11 @@
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::{GetCommitDiffRequest, GetDiffRequest, GetDiffResponse}; use crate::pb::{GetCommitDiffRequest, GetDiffRequest, GetDiffResponse};
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn get_commit_diff(&self, request: GetCommitDiffRequest) -> GitResult<GetDiffResponse> { pub fn get_commit_diff(&self, request: GetCommitDiffRequest) -> GitResult<GetDiffResponse> {
let commit = match request.commit.and_then(|s| s.selector) { let commit = resolve_revision!(request.commit);
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let base = self.first_parent_or_empty_tree(&commit)?; let base = self.first_parent_or_empty_tree(&commit)?;
self.get_diff(GetDiffRequest { self.get_diff(GetDiffRequest {
repository: request.repository, repository: request.repository,
+12 -3
View File
@@ -19,16 +19,25 @@ struct RawDiffEntry {
similarity: f64, similarity: f64,
} }
/// Type alias for diff raw output: (entries, numstat_map)
type DiffRawOutput = (Vec<RawDiffEntry>, HashMap<String, (u32, u32, bool)>);
impl GitBare { impl GitBare {
pub fn get_diff(&self, request: GetDiffRequest) -> GitResult<GetDiffResponse> { pub fn get_diff(&self, request: GetDiffRequest) -> GitResult<GetDiffResponse> {
let base = match request.base.and_then(|s| s.selector) { let base = match request.base.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(), Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(),
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision.clone(), Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision.clone()
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
let head = match request.head.and_then(|s| s.selector) { let head = match request.head.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(), Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(),
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision.clone(), Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision.clone()
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
tracing::debug!( tracing::debug!(
@@ -142,7 +151,7 @@ impl GitBare {
base: &str, base: &str,
head: &str, head: &str,
options: Option<&crate::pb::DiffOptions>, options: Option<&crate::pb::DiffOptions>,
) -> GitResult<(Vec<RawDiffEntry>, HashMap<String, (u32, u32, bool)>)> { ) -> GitResult<DiffRawOutput> {
let mut args = vec![ let mut args = vec![
"--git-dir".to_string(), "--git-dir".to_string(),
self.bare_dir.to_string_lossy().into_owned(), self.bare_dir.to_string_lossy().into_owned(),
+3 -10
View File
@@ -1,19 +1,12 @@
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::GetDiffStatsRequest; use crate::pb::GetDiffStatsRequest;
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn get_diff_stats(&self, request: GetDiffStatsRequest) -> GitResult<crate::pb::DiffStats> { pub fn get_diff_stats(&self, request: GetDiffStatsRequest) -> GitResult<crate::pb::DiffStats> {
let base = match request.base.and_then(|s| s.selector) { let base = resolve_revision!(request.base);
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, let head = resolve_revision!(request.head);
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let head = match request.head.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
diff_stats_for_range(self, &base, &head, request.options.as_ref()) diff_stats_for_range(self, &base, &head, request.options.as_ref())
} }
} }
+3 -10
View File
@@ -1,19 +1,12 @@
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::{GetPatchRequest, GetPatchResponse}; use crate::pb::{GetPatchRequest, GetPatchResponse};
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn get_patch(&self, request: GetPatchRequest) -> GitResult<Vec<GetPatchResponse>> { pub fn get_patch(&self, request: GetPatchRequest) -> GitResult<Vec<GetPatchResponse>> {
let base = match request.base.and_then(|s| s.selector) { let base = resolve_revision!(request.base);
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, let head = resolve_revision!(request.head);
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let head = match request.head.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let result = duct::cmd( let result = duct::cmd(
"git", "git",
[ [
+3 -1
View File
@@ -1,3 +1,4 @@
pub mod actor;
pub mod archive; pub mod archive;
pub mod bare; pub mod bare;
pub mod blame; pub mod blame;
@@ -7,13 +8,14 @@ pub mod commit;
pub mod diff; pub mod diff;
pub mod error; pub mod error;
pub mod init; pub mod init;
pub mod macros;
pub mod merge; pub mod merge;
pub mod oid; pub mod oid;
pub mod pack; pub mod pack;
pub mod paginate; pub mod paginate;
pub mod pb; pub mod pb;
pub mod refs; pub mod refs;
pub mod sanitize;
pub mod server; pub mod server;
pub mod tag; pub mod tag;
pub mod tree; pub mod tree;
pub mod actor;
+36
View File
@@ -0,0 +1,36 @@
//! Helper macro to extract and validate a revision selector.
//!
//! Replaces the repeated pattern of matching on ObjectSelector variants
//! and validating revision strings before use.
/// Extract a revision string from an optional ObjectSelector, applying
/// validation to revision names. OID hex strings are always safe.
///
/// Returns "HEAD" when selector is None.
///
/// Usage:
/// let revision = resolve_revision!(request.base);
/// let revision = resolve_revision!(request.base, "main");
#[macro_export]
macro_rules! resolve_revision {
($selector:expr) => {{
match $selector.and_then(|s| s.selector) {
Some($crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some($crate::pb::object_selector::Selector::Revision(name)) => {
$crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => "HEAD".into(),
}
}};
($selector:expr, $default:expr) => {{
match $selector.and_then(|s| s.selector) {
Some($crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some($crate::pb::object_selector::Selector::Revision(name)) => {
$crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => ($default).into(),
}
}};
}
+10 -15
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use gitks::actor::init_actor_cluster; use gitks::actor::init_actor_cluster;
use gitks::server::{serve, GitksService}; use gitks::server::{GitksService, serve};
const DEFAULT_HOST: &str = "0.0.0.0"; const DEFAULT_HOST: &str = "0.0.0.0";
const DEFAULT_PORT: &str = "50051"; const DEFAULT_PORT: &str = "50051";
@@ -12,16 +12,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
tracing::info!( tracing::info!(version = env!("CARGO_PKG_VERSION"), "gitks starting up");
version = env!("CARGO_PKG_VERSION"),
"gitks starting up"
);
let host = std::env::var("GITKS_HOST").unwrap_or_else(|_| DEFAULT_HOST.into()); let host = std::env::var("GITKS_HOST").unwrap_or_else(|_| DEFAULT_HOST.into());
let port = std::env::var("GITKS_PORT").unwrap_or_else(|_| DEFAULT_PORT.into()); let port = std::env::var("GITKS_PORT").unwrap_or_else(|_| DEFAULT_PORT.into());
let storage_name = std::env::var("STORAGE_NAME").unwrap_or_else(|_| DEFAULT_STORAGE_NAME.into()); let storage_name =
let grpc_addr = std::env::var("GITKS_ADVERTISE_ADDR") std::env::var("STORAGE_NAME").unwrap_or_else(|_| DEFAULT_STORAGE_NAME.into());
.unwrap_or_else(|_| format!("http://{host}:{port}")); let grpc_addr =
std::env::var("GITKS_ADVERTISE_ADDR").unwrap_or_else(|_| format!("http://{host}:{port}"));
let repo_prefix = std::env::var("REPO_PREFIX_PATH") let repo_prefix = std::env::var("REPO_PREFIX_PATH")
.map_err(|_| "REPO_PREFIX_PATH environment variable is required (e.g. /data/repos)")?; .map_err(|_| "REPO_PREFIX_PATH environment variable is required (e.g. /data/repos)")?;
@@ -35,13 +33,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
let addr: std::net::SocketAddr = format!("{host}:{port}").parse()?; let addr: std::net::SocketAddr = format!("{host}:{port}").parse()?;
let actor_svc = GitksService::new(repo_prefix.clone()); let svc = GitksService::new(repo_prefix.clone());
let (node_actor, node_handle) = init_actor_cluster( let (node_actor, node_handle) =
actor_svc, init_actor_cluster(svc.clone(), storage_name.clone(), grpc_addr.clone()).await?;
storage_name.clone(), let svc = svc
grpc_addr.clone(),
).await?;
let svc = GitksService::new(repo_prefix.clone())
.with_actor(node_actor.clone()) .with_actor(node_actor.clone())
.with_grpc_addr(grpc_addr.clone()); .with_grpc_addr(grpc_addr.clone());
+3 -10
View File
@@ -1,19 +1,12 @@
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::GitResult; use crate::error::GitResult;
use crate::pb::{CheckMergeRequest, MergeResult, merge_result}; use crate::pb::{CheckMergeRequest, MergeResult, merge_result};
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn check_merge(&self, request: CheckMergeRequest) -> GitResult<MergeResult> { pub fn check_merge(&self, request: CheckMergeRequest) -> GitResult<MergeResult> {
let target = match request.target.and_then(|s| s.selector) { let target = resolve_revision!(request.target);
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, let source = resolve_revision!(request.source);
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let source = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let target_id = repo.rev_parse_single(target.as_str())?; let target_id = repo.rev_parse_single(target.as_str())?;
+5 -1
View File
@@ -5,9 +5,13 @@ use crate::pb::{MergeRequest, MergeResult, merge_result};
impl GitBare { impl GitBare {
pub fn merge(&self, request: MergeRequest) -> GitResult<MergeResult> { pub fn merge(&self, request: MergeRequest) -> GitResult<MergeResult> {
let target_branch = request.target_branch.clone(); let target_branch = request.target_branch.clone();
crate::sanitize::validate_ref_name(&target_branch)?;
let source_revision = match request.source.and_then(|s| s.selector) { let source_revision = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(), Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex.clone(),
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision.clone(), Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision.clone()
}
None => return Err(GitError::InvalidArgument("source is required".into())), None => return Err(GitError::InvalidArgument("source is required".into())),
}; };
tracing::info!( tracing::info!(
+8 -2
View File
@@ -10,12 +10,18 @@ impl GitBare {
) -> GitResult<ListMergeConflictsResponse> { ) -> GitResult<ListMergeConflictsResponse> {
let target = match request.target.and_then(|s| s.selector) { let target = match request.target.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => return Err(GitError::InvalidArgument("target is required".into())), None => return Err(GitError::InvalidArgument("target is required".into())),
}; };
let source = match request.source.and_then(|s| s.selector) { let source = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => return Err(GitError::InvalidArgument("source is required".into())), None => return Err(GitError::InvalidArgument("source is required".into())),
}; };
+5 -1
View File
@@ -6,9 +6,13 @@ use crate::pb::{RebaseRequest, RebaseResult, rebase_result};
impl GitBare { impl GitBare {
pub fn rebase(&self, request: RebaseRequest) -> GitResult<RebaseResult> { pub fn rebase(&self, request: RebaseRequest) -> GitResult<RebaseResult> {
let branch = request.branch.clone(); let branch = request.branch.clone();
crate::sanitize::validate_ref_name(&branch)?;
let upstream_revision = match request.upstream.and_then(|s| s.selector) { let upstream_revision = match request.upstream.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => return Err(GitError::InvalidArgument("upstream is required".into())), None => return Err(GitError::InvalidArgument("upstream is required".into())),
}; };
+5 -1
View File
@@ -9,9 +9,13 @@ impl GitBare {
request: ResolveMergeConflictsRequest, request: ResolveMergeConflictsRequest,
) -> GitResult<MergeResult> { ) -> GitResult<MergeResult> {
let target_branch = request.target_branch.clone(); let target_branch = request.target_branch.clone();
crate::sanitize::validate_ref_name(&target_branch)?;
let source_revision = match request.source.and_then(|s| s.selector) { let source_revision = match request.source.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => return Err(GitError::InvalidArgument("source is required".into())), None => return Err(GitError::InvalidArgument("source is required".into())),
}; };
+307
View File
@@ -0,0 +1,307 @@
//! Input sanitization for git subprocess arguments.
//!
//! Prevents command injection by validating user-supplied strings before
//! passing them to git commands.
use crate::error::GitError;
use crate::error::GitResult;
/// Characters that are never allowed in git ref names / revision strings.
const FORBIDDEN_REF_CHARS: &[char] = &[
'~', '^', ':', '?', '*', '[', '\\', ' ', '\n', '\r', '\t', '\0',
];
/// Validate a git reference name (branch, tag, etc.).
///
/// Git ref rules (from `git check-ref-format`):
/// - Cannot contain forbidden chars
/// - Cannot start or end with '.'
/// - Cannot end with '/'
/// - Cannot contain '..'
/// - Cannot contain '@{'
/// - Cannot be empty
pub fn validate_ref_name(name: &str) -> GitResult<()> {
if name.is_empty() {
return Err(GitError::InvalidArgument("ref name cannot be empty".into()));
}
if name.starts_with('.') || name.ends_with('.') {
return Err(GitError::InvalidArgument(format!(
"ref name cannot start or end with '.': {name}"
)));
}
if name.ends_with('/') {
return Err(GitError::InvalidArgument(format!(
"ref name cannot end with '/': {name}"
)));
}
if name.contains("..") {
return Err(GitError::InvalidArgument(format!(
"ref name cannot contain '..': {name}"
)));
}
if name.contains("@{") {
return Err(GitError::InvalidArgument(format!(
"ref name cannot contain '@{{': {name}"
)));
}
if name.contains(|c: char| FORBIDDEN_REF_CHARS.contains(&c)) {
return Err(GitError::InvalidArgument(format!(
"ref name contains forbidden character: {name}"
)));
}
// Ref names must not exceed a reasonable length
if name.len() > 255 {
return Err(GitError::InvalidArgument(format!(
"ref name too long (max 255 chars): {name}"
)));
}
Ok(())
}
/// Validate a revision string (branch name, tag name, or short expression).
///
/// Allows OID hex strings, ref names, and a small set of revision operators
/// (HEAD, ^{tree}, ~N, ^N) that are safe when passed as a single argument.
pub fn validate_revision(rev: &str) -> GitResult<()> {
if rev.is_empty() {
return Err(GitError::InvalidArgument("revision cannot be empty".into()));
}
// Prevent DoS via extremely long revision strings
if rev.len() > 256 {
return Err(GitError::InvalidArgument(format!(
"revision too long (max 256 chars): {}",
rev.len()
)));
}
// Pure hex OID — always safe
if rev.chars().all(|c| c.is_ascii_hexdigit()) && rev.len() >= 4 && rev.len() <= 64 {
return Ok(());
}
// HEAD is always safe
if rev == "HEAD" {
return Ok(());
}
// Allow ref:refs/heads/... (git internal format)
if let Some(rest) = rev.strip_prefix("ref:") {
return validate_ref_name(rest.trim());
}
// Validate ~N and ^N numeric suffixes to prevent DoS
const MAX_ANCESTRY_DEPTH: u32 = 10000;
// Check for ~N syntax
if let Some(tilde_pos) = rev.rfind('~') {
let num_part = &rev[tilde_pos + 1..];
if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) {
let depth: u32 = num_part
.parse()
.map_err(|_| GitError::InvalidArgument("invalid ~N syntax".into()))?;
if depth > MAX_ANCESTRY_DEPTH {
return Err(GitError::InvalidArgument(format!(
"~N depth too large: {} (max {})",
depth, MAX_ANCESTRY_DEPTH
)));
}
}
}
// Check for ^N syntax (not ^{tree})
if let Some(caret_pos) = rev.rfind('^') {
let after_caret = &rev[caret_pos + 1..];
// Skip ^{tree} style operators
if !after_caret.starts_with('{')
&& !after_caret.is_empty()
&& let Some(first_char) = after_caret.chars().next()
&& first_char.is_ascii_digit()
{
let num_part: String = after_caret
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !num_part.is_empty() {
let depth: u32 = num_part
.parse()
.map_err(|_| GitError::InvalidArgument("invalid ^N syntax".into()))?;
if depth > MAX_ANCESTRY_DEPTH {
return Err(GitError::InvalidArgument(format!(
"^N depth too large: {} (max {})",
depth, MAX_ANCESTRY_DEPTH
)));
}
}
}
}
// Strip trailing operators and validate the base ref
// Only strip digits that are part of ~N or ^N patterns, not arbitrary trailing digits
let mut base = rev;
// Strip ^{tree}, ^{commit}, ^{object} suffixes
base = base
.trim_end_matches("^{tree}")
.trim_end_matches("^{commit}")
.trim_end_matches("^{object}");
// Strip ~N or ^N suffix if present
if let Some(tilde_pos) = base.rfind('~') {
let after_tilde = &base[tilde_pos + 1..];
if !after_tilde.is_empty() && after_tilde.chars().all(|c| c.is_ascii_digit()) {
base = &base[..tilde_pos];
}
} else if let Some(caret_pos) = base.rfind('^') {
let after_caret = &base[caret_pos + 1..];
if !after_caret.starts_with('{')
&& !after_caret.is_empty()
&& after_caret.chars().all(|c| c.is_ascii_digit())
{
base = &base[..caret_pos];
}
}
if base.is_empty() {
// Pure operator like "^" — unlikely but not dangerous
return Ok(());
}
validate_ref_name(base)?;
Ok(())
}
/// Validate a file path within a commit action.
///
/// Must be a relative path (no leading '/'), no '..' traversal,
/// no null bytes, no .git directory access, and reasonable length.
pub fn validate_file_path(path: &str) -> GitResult<()> {
if path.is_empty() {
return Err(GitError::InvalidArgument(
"file path cannot be empty".into(),
));
}
if path.starts_with('/') {
return Err(GitError::InvalidArgument(format!(
"file path must be relative, not absolute: {path}"
)));
}
if path.contains("..") {
return Err(GitError::InvalidArgument(format!(
"file path cannot contain '..': {path}"
)));
}
if path.contains('\0') {
return Err(GitError::InvalidArgument(format!(
"file path cannot contain null byte: {path}"
)));
}
if path.len() > 4096 {
return Err(GitError::InvalidArgument(format!(
"file path too long (max 4096 chars): {path}"
)));
}
// Prevent modification of .git directory
if path == ".git"
|| path.starts_with(".git/")
|| path.contains("/.git/")
|| path.ends_with("/.git")
{
return Err(GitError::InvalidArgument(format!(
"cannot modify .git directory: {path}"
)));
}
// Windows reserved names check
#[cfg(target_os = "windows")]
{
const RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
// Check each path component
for component in path.split('/') {
// Get filename without extension
let name_part = component.split('.').next().unwrap_or(component);
let name_upper = name_part.to_uppercase();
if RESERVED_NAMES.contains(&name_upper.as_str()) {
return Err(GitError::InvalidArgument(format!(
"Windows reserved device name: {component}"
)));
}
}
}
Ok(())
}
/// Git config keys that are dangerous to set remotely.
/// Setting these could allow arbitrary command execution or bypass security.
const DANGEROUS_CONFIG_KEYS: &[&str] = &[
"core.sshCommand",
"core.gitProxy",
"http.proxy",
"https.proxy",
"remote.*.url",
"credential.*",
"safe.directory",
"core.hooksPath",
"receive.fsckObjects",
"receive.denyCurrentBranch",
"receive.denyDeleteCurrent",
];
/// Check if a git config key is safe to set remotely.
pub fn validate_config_key(key: &str) -> GitResult<()> {
if key.is_empty() {
return Err(GitError::InvalidArgument(
"config key cannot be empty".into(),
));
}
// Check for wildcard patterns like "remote.*.url"
for pattern in DANGEROUS_CONFIG_KEYS {
if pattern.contains('*') {
// e.g. "remote.*.url" — match any "remote.<something>.url"
let (prefix, suffix) = pattern.split_once('*').unwrap();
if key.starts_with(prefix) && key.ends_with(suffix) {
return Err(GitError::InvalidArgument(format!(
"config key '{key}' matches dangerous pattern '{pattern}'"
)));
}
} else if key == *pattern {
return Err(GitError::InvalidArgument(format!(
"config key '{key}' is not allowed to be set remotely"
)));
}
}
// Config keys must be valid format: section.subsection.key
if !key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
{
return Err(GitError::InvalidArgument(format!(
"config key contains invalid characters: {key}"
)));
}
Ok(())
}
/// Validate a storage-relative path (used in resolve_for_init and from_repository_header).
///
/// Must not contain path traversal, must be a simple relative path.
pub fn validate_relative_path(path: &str) -> GitResult<()> {
if path.is_empty() {
return Err(GitError::InvalidArgument(
"relative_path cannot be empty".into(),
));
}
if path.starts_with('/') {
return Err(GitError::InvalidArgument(
"relative_path must be relative, not absolute".into(),
));
}
if path.contains("..") {
return Err(GitError::InvalidArgument(format!(
"path traversal detected: relative_path contains '..': {path}"
)));
}
Ok(())
}
+8 -22
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::archive_service_client::ArchiveServiceClient; use crate::pb::archive_service_client::ArchiveServiceClient;
use crate::pb::*;
use super::{GitksService, cache, into_status}; use super::{GitksService, cache, into_status};
async fn remote_archive_client( remote_client!(remote_archive_client, ArchiveServiceClient<tonic::transport::Channel>, "archive");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<ArchiveServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding archive rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = ArchiveServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl archive_service_server::ArchiveService for GitksService { impl archive_service_server::ArchiveService for GitksService {
@@ -39,7 +21,9 @@ impl archive_service_server::ArchiveService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_archive_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_archive_client(self, inner.repository.as_ref(), false).await?
{
let resp = client.get_archive(inner).await?; let resp = client.get_archive(inner).await?;
let stream = super::bridge_server_stream(resp.into_inner()); let stream = super::bridge_server_stream(resp.into_inner());
return Ok(tonic::Response::new(stream)); return Ok(tonic::Response::new(stream));
@@ -64,7 +48,9 @@ impl archive_service_server::ArchiveService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_archive_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_archive_client(self, inner.repository.as_ref(), false).await?
{
return client.list_archive_entries(inner).await; return client.list_archive_entries(inner).await;
} }
return Err(err); return Err(err);
+8 -22
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::blame_service_client::BlameServiceClient; use crate::pb::blame_service_client::BlameServiceClient;
use crate::pb::*;
use super::{GitksService, cache, into_status, into_stream}; use super::{GitksService, cache, into_status, into_stream};
async fn remote_blame_client( remote_client!(remote_blame_client, BlameServiceClient<tonic::transport::Channel>, "blame");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<BlameServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding blame rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = BlameServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl blame_service_server::BlameService for GitksService { impl blame_service_server::BlameService for GitksService {
@@ -40,7 +22,9 @@ impl blame_service_server::BlameService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_blame_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_blame_client(self, inner.repository.as_ref(), false).await?
{
return client.blame(inner).await; return client.blame(inner).await;
} }
return Err(err); return Err(err);
@@ -70,7 +54,9 @@ impl blame_service_server::BlameService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_blame_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_blame_client(self, inner.repository.as_ref(), false).await?
{
let resp = client.stream_blame(inner).await?; let resp = client.stream_blame(inner).await?;
let stream = super::bridge_server_stream(resp.into_inner()); let stream = super::bridge_server_stream(resp.into_inner());
return Ok(tonic::Response::new(stream)); return Ok(tonic::Response::new(stream));
+26 -28
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::branch_service_client::BranchServiceClient; use crate::pb::branch_service_client::BranchServiceClient;
use crate::pb::*;
use super::{GitksService, into_status}; use super::{GitksService, into_status};
async fn remote_branch_client( remote_client!(remote_branch_client, BranchServiceClient<tonic::transport::Channel>, "branch");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<BranchServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding branch rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = BranchServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl branch_service_server::BranchService for GitksService { impl branch_service_server::BranchService for GitksService {
@@ -36,7 +18,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), false).await?
{
return client.list_branches(inner).await; return client.list_branches(inner).await;
} }
return Err(err); return Err(err);
@@ -60,7 +44,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), false).await?
{
return client.get_branch(inner).await; return client.get_branch(inner).await;
} }
return Err(err); return Err(err);
@@ -83,7 +69,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), true).await?
{
return client.create_branch(inner).await; return client.create_branch(inner).await;
} }
return Err(err); return Err(err);
@@ -108,7 +96,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), true).await?
{
return client.delete_branch(inner).await; return client.delete_branch(inner).await;
} }
return Err(err); return Err(err);
@@ -134,7 +124,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), true).await?
{
return client.rename_branch(inner).await; return client.rename_branch(inner).await;
} }
return Err(err); return Err(err);
@@ -159,7 +151,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), true).await?
{
return client.update_branch_target(inner).await; return client.update_branch_target(inner).await;
} }
return Err(err); return Err(err);
@@ -184,7 +178,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), true).await?
{
return client.set_branch_upstream(inner).await; return client.set_branch_upstream(inner).await;
} }
return Err(err); return Err(err);
@@ -210,7 +206,9 @@ impl branch_service_server::BranchService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_branch_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_branch_client(self, inner.repository.as_ref(), false).await?
{
return client.compare_branch(inner).await; return client.compare_branch(inner).await;
} }
return Err(err); return Err(err);
+80 -30
View File
@@ -1,22 +1,22 @@
use std::num::NonZeroUsize; use std::sync::OnceLock;
use std::sync::{Mutex, OnceLock}; use std::time::Duration;
use clru::CLruCache; use moka::sync::Cache;
use prost::Message; use prost::Message;
use crate::pb::{ObjectSelector, object_selector}; use crate::pb::{ObjectSelector, object_selector};
const GLOBAL_CACHE_MAX: usize = 65_545; const GLOBAL_CACHE_MAX: u64 = 65_536;
const CACHE_TTL: Duration = Duration::from_secs(300);
type Cache = CLruCache<Vec<u8>, Vec<u8>>; static GLOBAL_CACHE: OnceLock<Cache<Vec<u8>, Vec<u8>>> = OnceLock::new();
static GLOBAL_CACHE: OnceLock<Mutex<Cache>> = OnceLock::new(); fn cache() -> &'static Cache<Vec<u8>, Vec<u8>> {
fn cache() -> &'static Mutex<Cache> {
GLOBAL_CACHE.get_or_init(|| { GLOBAL_CACHE.get_or_init(|| {
let capacity = Cache::builder()
NonZeroUsize::new(GLOBAL_CACHE_MAX).expect("cache capacity must be non-zero"); .max_capacity(GLOBAL_CACHE_MAX)
Mutex::new(CLruCache::new(capacity)) .time_to_live(CACHE_TTL)
.build()
}) })
} }
@@ -45,11 +45,7 @@ where
{ {
let key = cache_key(namespace, request); let key = cache_key(namespace, request);
if let Some(bytes) = cache() if let Some(bytes) = cache().get(&key)
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
.cloned()
&& let Ok(response) = Res::decode(bytes.as_slice()) && let Ok(response) = Res::decode(bytes.as_slice())
{ {
tracing::debug!( tracing::debug!(
@@ -70,10 +66,7 @@ where
response response
.encode(&mut bytes) .encode(&mut bytes)
.expect("encoding a prost message into Vec cannot fail"); .expect("encoding a prost message into Vec cannot fail");
cache() cache().insert(key, bytes);
.lock()
.unwrap_or_else(|e| e.into_inner())
.put(key, bytes);
Ok(response) Ok(response)
} }
@@ -89,12 +82,7 @@ where
{ {
let key = cache_key(namespace, request); let key = cache_key(namespace, request);
if let Some(bytes) = cache() if let Some(bytes) = cache().get(&key) {
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
.cloned()
{
let mut remaining = bytes.as_slice(); let mut remaining = bytes.as_slice();
let mut items = Vec::new(); let mut items = Vec::new();
let mut valid = true; let mut valid = true;
@@ -133,13 +121,75 @@ where
item.encode_length_delimited(&mut bytes) item.encode_length_delimited(&mut bytes)
.expect("encoding a prost message into Vec cannot fail"); .expect("encoding a prost message into Vec cannot fail");
} }
cache() cache().insert(key, bytes);
.lock()
.unwrap_or_else(|e| e.into_inner())
.put(key, bytes);
Ok(response) Ok(response)
} }
/// Invalidate all cache entries related to a specific repository.
/// Called when refs are updated (create branch, create commit, etc.)
/// so that stale data is not served.
pub(crate) fn invalidate_repo(relative_path: &str) {
let c = cache();
// Encode the relative_path to match how it appears in cache keys
let target_path_bytes = relative_path.as_bytes();
// Remove all keys that reference this repository
// Cache keys are: namespace\0 + prost-encoded request
let keys_to_remove: Vec<std::sync::Arc<Vec<u8>>> = c
.iter()
.filter_map(|(key, _)| {
// Find the null byte separator
if let Some(null_pos) = key.iter().position(|&b| b == 0) {
let encoded_request = &key[null_pos + 1..];
// Check if this encoded request contains the repository path
// We use a sliding window to find the path bytes in the encoded protobuf
// This is conservative but correct: we may invalidate slightly more than
// necessary, but we won't miss any entries for this repository.
//
// The encoded protobuf format embeds string fields as length-prefixed data,
// so the relative_path bytes should appear verbatim somewhere in the message.
if contains_subslice(encoded_request, target_path_bytes) {
return Some(key);
}
} else {
// Malformed key without separator, remove it to be safe
tracing::warn!("found cache key without null separator, removing");
return Some(key);
}
None
})
.collect();
let removed = keys_to_remove.len();
for key in keys_to_remove {
c.invalidate(key.as_ref());
}
if removed > 0 {
tracing::debug!(
relative_path = %relative_path,
entries_removed = removed,
"cache invalidated for repository"
);
}
}
/// Check if a byte slice contains a subslice
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
if needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
pub(crate) fn selector_is_oid(selector: &Option<ObjectSelector>) -> bool { pub(crate) fn selector_is_oid(selector: &Option<ObjectSelector>) -> bool {
matches!( matches!(
selector.as_ref().and_then(|s| s.selector.as_ref()), selector.as_ref().and_then(|s| s.selector.as_ref()),
+26 -28
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::commit_service_client::CommitServiceClient; use crate::pb::commit_service_client::CommitServiceClient;
use crate::pb::*;
use super::{GitksService, cache, into_status}; use super::{GitksService, cache, into_status};
async fn remote_commit_client( remote_client!(remote_commit_client, CommitServiceClient<tonic::transport::Channel>, "commit");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<CommitServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding commit rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = CommitServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl commit_service_server::CommitService for GitksService { impl commit_service_server::CommitService for GitksService {
@@ -36,7 +18,9 @@ impl commit_service_server::CommitService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_commit_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_commit_client(self, inner.repository.as_ref(), false).await?
{
return client.list_commits(inner).await; return client.list_commits(inner).await;
} }
return Err(err); return Err(err);
@@ -65,7 +49,9 @@ impl commit_service_server::CommitService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_commit_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_commit_client(self, inner.repository.as_ref(), false).await?
{
return client.get_commit(inner).await; return client.get_commit(inner).await;
} }
return Err(err); return Err(err);
@@ -93,7 +79,9 @@ impl commit_service_server::CommitService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_commit_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_commit_client(self, inner.repository.as_ref(), false).await?
{
return client.get_commit_ancestors(inner).await; return client.get_commit_ancestors(inner).await;
} }
return Err(err); return Err(err);
@@ -123,7 +111,9 @@ impl commit_service_server::CommitService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_commit_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_commit_client(self, inner.repository.as_ref(), true).await?
{
return client.create_commit(inner).await; return client.create_commit(inner).await;
} }
return Err(err); return Err(err);
@@ -131,7 +121,9 @@ impl commit_service_server::CommitService for GitksService {
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
let resp = gb.create_commit(inner).map_err(into_status)?; let resp = gb.create_commit(inner).map_err(into_status)?;
let commit_hex = resp.commit.as_ref() let commit_hex = resp
.commit
.as_ref()
.and_then(|c| c.oid.as_ref().map(|o| o.hex.as_str()).or(Some("?"))) .and_then(|c| c.oid.as_ref().map(|o| o.hex.as_str()).or(Some("?")))
.unwrap_or("?"); .unwrap_or("?");
tracing::info!(%repo, %branch, %commit_hex, "commit created"); tracing::info!(%repo, %branch, %commit_hex, "commit created");
@@ -151,7 +143,9 @@ impl commit_service_server::CommitService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_commit_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_commit_client(self, inner.repository.as_ref(), true).await?
{
return client.revert_commit(inner).await; return client.revert_commit(inner).await;
} }
return Err(err); return Err(err);
@@ -176,7 +170,9 @@ impl commit_service_server::CommitService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_commit_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_commit_client(self, inner.repository.as_ref(), true).await?
{
return client.cherry_pick_commit(inner).await; return client.cherry_pick_commit(inner).await;
} }
return Err(err); return Err(err);
@@ -200,7 +196,9 @@ impl commit_service_server::CommitService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_commit_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_commit_client(self, inner.repository.as_ref(), false).await?
{
return client.compare_commits(inner).await; return client.compare_commits(inner).await;
} }
return Err(err); return Err(err);
+14 -24
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::diff_service_client::DiffServiceClient; use crate::pb::diff_service_client::DiffServiceClient;
use crate::pb::*;
use super::{GitksService, cache, into_status, into_stream}; use super::{GitksService, cache, into_status, into_stream};
async fn remote_diff_client( remote_client!(remote_diff_client, DiffServiceClient<tonic::transport::Channel>, "diff");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<DiffServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding diff rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = DiffServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl diff_service_server::DiffService for GitksService { impl diff_service_server::DiffService for GitksService {
@@ -39,7 +21,9 @@ impl diff_service_server::DiffService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_diff_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_diff_client(self, inner.repository.as_ref(), false).await?
{
return client.get_diff(inner).await; return client.get_diff(inner).await;
} }
return Err(err); return Err(err);
@@ -68,7 +52,9 @@ impl diff_service_server::DiffService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_diff_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_diff_client(self, inner.repository.as_ref(), false).await?
{
return client.get_commit_diff(inner).await; return client.get_commit_diff(inner).await;
} }
return Err(err); return Err(err);
@@ -97,7 +83,9 @@ impl diff_service_server::DiffService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_diff_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_diff_client(self, inner.repository.as_ref(), false).await?
{
let resp = client.get_patch(inner).await?; let resp = client.get_patch(inner).await?;
let stream = super::bridge_server_stream(resp.into_inner()); let stream = super::bridge_server_stream(resp.into_inner());
return Ok(tonic::Response::new(stream)); return Ok(tonic::Response::new(stream));
@@ -127,7 +115,9 @@ impl diff_service_server::DiffService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_diff_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_diff_client(self, inner.repository.as_ref(), false).await?
{
return client.get_diff_stats(inner).await; return client.get_diff_stats(inner).await;
} }
return Err(err); return Err(err);
+17 -25
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::merge_service_client::MergeServiceClient; use crate::pb::merge_service_client::MergeServiceClient;
use crate::pb::*;
use super::{GitksService, into_status}; use super::{GitksService, into_status};
async fn remote_merge_client( remote_client!(remote_merge_client, MergeServiceClient<tonic::transport::Channel>, "merge");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<MergeServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding merge rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = MergeServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl merge_service_server::MergeService for GitksService { impl merge_service_server::MergeService for GitksService {
@@ -36,7 +18,9 @@ impl merge_service_server::MergeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_merge_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_merge_client(self, inner.repository.as_ref(), false).await?
{
return client.check_merge(inner).await; return client.check_merge(inner).await;
} }
return Err(err); return Err(err);
@@ -60,7 +44,9 @@ impl merge_service_server::MergeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_merge_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_merge_client(self, inner.repository.as_ref(), true).await?
{
return client.merge(inner).await; return client.merge(inner).await;
} }
return Err(err); return Err(err);
@@ -84,7 +70,9 @@ impl merge_service_server::MergeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_merge_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_merge_client(self, inner.repository.as_ref(), false).await?
{
return client.list_merge_conflicts(inner).await; return client.list_merge_conflicts(inner).await;
} }
return Err(err); return Err(err);
@@ -108,7 +96,9 @@ impl merge_service_server::MergeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_merge_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_merge_client(self, inner.repository.as_ref(), true).await?
{
return client.resolve_merge_conflicts(inner).await; return client.resolve_merge_conflicts(inner).await;
} }
return Err(err); return Err(err);
@@ -133,7 +123,9 @@ impl merge_service_server::MergeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_merge_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_merge_client(self, inner.repository.as_ref(), true).await?
{
return client.rebase(inner).await; return client.rebase(inner).await;
} }
return Err(err); return Err(err);
+93 -18
View File
@@ -1,3 +1,35 @@
/// Generate a `remote_<service>_client` helper function that resolves a repository
/// route and returns a connected gRPC client for the given service.
macro_rules! remote_client {
($fn_name:ident, $client:ty, $svc_label:literal) => {
async fn $fn_name(
svc: &super::GitksService,
header: Option<&crate::pb::RepositoryHeader>,
is_write: bool,
) -> Result<Option<$client>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(
storage_name = %route.storage_name,
relative_path = %route.relative_path,
actor_name = %route.actor_name,
grpc_addr = %route.grpc_addr,
concat!("forwarding ", $svc_label, " rpc")
);
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = <$client>::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
};
}
mod archive; mod archive;
mod blame; mod blame;
mod branch; mod branch;
@@ -11,9 +43,9 @@ mod repository_maint;
mod tag; mod tag;
mod tree; mod tree;
use std::path::{Path, PathBuf};
use gix::discover::is_git; use gix::discover::is_git;
use ractor::{ActorCell, ActorRef}; use ractor::{ActorCell, ActorRef};
use std::path::{Path, PathBuf};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use crate::actor::message::{GitNodeMessage, RouteDecision}; use crate::actor::message::{GitNodeMessage, RouteDecision};
@@ -34,7 +66,11 @@ pub struct GitksService {
impl GitksService { impl GitksService {
pub fn new(repo_prefix: PathBuf) -> Self { pub fn new(repo_prefix: PathBuf) -> Self {
Self { repo_prefix, node_actor: None, grpc_addr: String::new() } Self {
repo_prefix,
node_actor: None,
grpc_addr: String::new(),
}
} }
pub fn with_actor(mut self, node_actor: ActorRef<GitNodeMessage>) -> Self { pub fn with_actor(mut self, node_actor: ActorRef<GitNodeMessage>) -> Self {
@@ -74,22 +110,25 @@ impl GitksService {
if local.as_ref().is_some_and(|actor| actor == &member) { if local.as_ref().is_some_and(|actor| actor == &member) {
continue; continue;
} }
if let Some(decision) = query_find_primary(member.clone(), header.clone()).await? { if let Some(decision) = query_find_primary(member.clone(), header.clone()).await?
if decision.found && !decision.grpc_addr.is_empty() { && decision.found
&& !decision.grpc_addr.is_empty()
{
primary = Some(decision); primary = Some(decision);
if is_write { if is_write {
return Ok(primary); return Ok(primary);
} }
} }
} if !is_write
if !is_write && replica.is_none() { && replica.is_none()
if let Some(decision) = query_find_replica(member.clone(), header.clone()).await? { && let Some(decision) = query_find_replica(member.clone(), header.clone()).await?
if decision.found && !decision.grpc_addr.is_empty() && decision.role == ROLE_REPLICA { && decision.found
&& !decision.grpc_addr.is_empty()
&& decision.role == ROLE_REPLICA
{
replica = Some(decision); replica = Some(decision);
} }
} }
}
}
if let Some(p) = primary { if let Some(p) = primary {
return Ok(Some(p)); return Ok(Some(p));
} }
@@ -142,20 +181,56 @@ impl GitksService {
if relative_path.is_empty() { if relative_path.is_empty() {
return Err(tonic::Status::invalid_argument("relative_path is required")); return Err(tonic::Status::invalid_argument("relative_path is required"));
} }
// Validate early to reject '..' and other traversal patterns
crate::sanitize::validate_relative_path(relative_path)
.map_err(|e| tonic::Status::invalid_argument(e.to_string()))?;
let candidate = self.repo_prefix.join(relative_path); let candidate = self.repo_prefix.join(relative_path);
// Path traversal check // Canonicalize repo_prefix (which should exist) for a reliable check
let canonical = candidate
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
let prefix_canon = self let prefix_canon = self
.repo_prefix .repo_prefix
.canonicalize() .canonicalize()
.unwrap_or_else(|_| self.repo_prefix.clone()); .unwrap_or_else(|_| self.repo_prefix.clone());
// Unified path validation to avoid TOCTOU
let canonical = match candidate.canonicalize() {
Ok(canon) => {
// Path exists and was canonicalized
canon
}
Err(_) => {
// Path doesn't exist yet — validate via parent
let parent = candidate.parent().unwrap_or(&self.repo_prefix);
let filename = candidate.file_name().ok_or_else(|| {
tonic::Status::invalid_argument("invalid path: missing filename")
})?;
let parent_canon = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
let constructed = parent_canon.join(filename);
// String-level verification for non-existent paths
let constructed_str = constructed.to_string_lossy();
let prefix_str = prefix_canon.to_string_lossy();
if !constructed_str.starts_with(&*prefix_str) {
return Err(tonic::Status::invalid_argument(
"path traversal detected: relative_path escapes repo prefix",
));
}
constructed
}
};
// Final check: canonical must be under prefix
if !canonical.starts_with(&prefix_canon) { if !canonical.starts_with(&prefix_canon) {
return Err(tonic::Status::invalid_argument( return Err(tonic::Status::invalid_argument(
"path traversal detected: relative_path escapes repo prefix", "path traversal detected: relative_path escapes repo prefix",
)); ));
} }
Ok(canonical) Ok(canonical)
} }
@@ -166,6 +241,9 @@ impl GitksService {
old_oid: &str, old_oid: &str,
new_oid: &str, new_oid: &str,
) { ) {
// Invalidate caches that depend on this repository
crate::server::cache::invalidate_repo(relative_path);
if let Some(ref actor) = self.node_actor { if let Some(ref actor) = self.node_actor {
let event = crate::actor::message::RefUpdateEvent { let event = crate::actor::message::RefUpdateEvent {
relative_path: relative_path.to_string(), relative_path: relative_path.to_string(),
@@ -189,14 +267,11 @@ impl GitksService {
} }
} }
pub async fn remote_endpoint(addr: &str) -> Result<tonic::transport::Endpoint, tonic::Status> { pub async fn remote_endpoint(addr: &str) -> Result<tonic::transport::Endpoint, tonic::Status> {
let uri: tonic::codegen::http::Uri = addr let uri: tonic::codegen::http::Uri = addr
.parse() .parse()
.map_err(|e| tonic::Status::invalid_argument(format!("invalid URI: {e}")))?; .map_err(|e| tonic::Status::invalid_argument(format!("invalid URI: {e}")))?;
tonic::transport::Endpoint::new(uri) tonic::transport::Endpoint::new(uri).map_err(|e| tonic::Status::internal(e.to_string()))
.map_err(|e| tonic::Status::internal(e.to_string()))
} }
pub(super) fn bridge_server_stream<T: Send + 'static>( pub(super) fn bridge_server_stream<T: Send + 'static>(
+43 -31
View File
@@ -1,30 +1,12 @@
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use crate::pb::*;
use crate::pb::pack_service_client::PackServiceClient; use crate::pb::pack_service_client::PackServiceClient;
use crate::pb::*;
use super::{GitksService, into_status}; use super::{GitksService, into_status};
async fn remote_pack_client( remote_client!(remote_pack_client, PackServiceClient<tonic::transport::Channel>, "pack");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<PackServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding pack rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = PackServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl pack_service_server::PackService for GitksService { impl pack_service_server::PackService for GitksService {
@@ -43,7 +25,9 @@ impl pack_service_server::PackService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_pack_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_pack_client(self, inner.repository.as_ref(), false).await?
{
return client.advertise_refs(inner).await; return client.advertise_refs(inner).await;
} }
return Err(err); return Err(err);
@@ -70,19 +54,27 @@ impl pack_service_server::PackService for GitksService {
let gb = match self.resolve(first.repository.as_ref()) { let gb = match self.resolve(first.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_pack_client(self, first.repository.as_ref(), false).await? { if let Some(mut client) =
remote_pack_client(self, first.repository.as_ref(), false).await?
{
let (tx, rx) = tokio::sync::mpsc::channel(16); let (tx, rx) = tokio::sync::mpsc::channel(16);
let _ = tx.send(first).await; let _ = tx.send(first).await;
tokio::spawn(async move { tokio::spawn(async move {
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
while let Some(msg) = stream.next().await { while let Some(msg) = stream.next().await {
match msg { match msg {
Ok(m) => { if tx.send(m).await.is_err() { break; } } Ok(m) => {
if tx.send(m).await.is_err() {
break;
}
}
Err(_) => break, Err(_) => break,
} }
} }
}); });
let resp = client.upload_pack(tokio_stream::wrappers::ReceiverStream::new(rx)).await?; let resp = client
.upload_pack(tokio_stream::wrappers::ReceiverStream::new(rx))
.await?;
let out = super::bridge_server_stream(resp.into_inner()); let out = super::bridge_server_stream(resp.into_inner());
return Ok(tonic::Response::new(out)); return Ok(tonic::Response::new(out));
} }
@@ -123,19 +115,27 @@ impl pack_service_server::PackService for GitksService {
let gb = match self.resolve(first.repository.as_ref()) { let gb = match self.resolve(first.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_pack_client(self, first.repository.as_ref(), false).await? { if let Some(mut client) =
remote_pack_client(self, first.repository.as_ref(), false).await?
{
let (tx, rx) = tokio::sync::mpsc::channel(16); let (tx, rx) = tokio::sync::mpsc::channel(16);
let _ = tx.send(first).await; let _ = tx.send(first).await;
tokio::spawn(async move { tokio::spawn(async move {
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
while let Some(msg) = stream.next().await { while let Some(msg) = stream.next().await {
match msg { match msg {
Ok(m) => { if tx.send(m).await.is_err() { break; } } Ok(m) => {
if tx.send(m).await.is_err() {
break;
}
}
Err(_) => break, Err(_) => break,
} }
} }
}); });
let resp = client.receive_pack(tokio_stream::wrappers::ReceiverStream::new(rx)).await?; let resp = client
.receive_pack(tokio_stream::wrappers::ReceiverStream::new(rx))
.await?;
let out = super::bridge_server_stream(resp.into_inner()); let out = super::bridge_server_stream(resp.into_inner());
return Ok(tonic::Response::new(out)); return Ok(tonic::Response::new(out));
} }
@@ -172,7 +172,9 @@ impl pack_service_server::PackService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_pack_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_pack_client(self, inner.repository.as_ref(), false).await?
{
let resp = client.pack_objects(inner).await?; let resp = client.pack_objects(inner).await?;
let stream = super::bridge_server_stream(resp.into_inner()); let stream = super::bridge_server_stream(resp.into_inner());
return Ok(tonic::Response::new(stream)); return Ok(tonic::Response::new(stream));
@@ -201,7 +203,13 @@ impl pack_service_server::PackService for GitksService {
let gb = match self.resolve(inputs.first().and_then(|r| r.repository.as_ref())) { let gb = match self.resolve(inputs.first().and_then(|r| r.repository.as_ref())) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_pack_client(self, inputs.first().and_then(|r| r.repository.as_ref()), false).await? { if let Some(mut client) = remote_pack_client(
self,
inputs.first().and_then(|r| r.repository.as_ref()),
false,
)
.await?
{
return client.index_pack(tokio_stream::iter(inputs)).await; return client.index_pack(tokio_stream::iter(inputs)).await;
} }
return Err(err); return Err(err);
@@ -224,7 +232,9 @@ impl pack_service_server::PackService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_pack_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_pack_client(self, inner.repository.as_ref(), false).await?
{
return client.list_packfiles(inner).await; return client.list_packfiles(inner).await;
} }
return Err(err); return Err(err);
@@ -247,7 +257,9 @@ impl pack_service_server::PackService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_pack_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_pack_client(self, inner.repository.as_ref(), false).await?
{
return client.fsck(inner).await; return client.fsck(inner).await;
} }
return Err(err); return Err(err);
+48 -38
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::repository_service_client::RepositoryServiceClient; use crate::pb::repository_service_client::RepositoryServiceClient;
use crate::pb::*;
use super::{GitksService, git_cmd, into_status, repository_maint, remote_endpoint}; use super::{GitksService, git_cmd, into_status, repository_maint};
async fn remote_repository_client( remote_client!(remote_repository_client, RepositoryServiceClient<tonic::transport::Channel>, "repository");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<RepositoryServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding repository rpc");
let endpoint = remote_endpoint(&route.grpc_addr).await?;
let client = RepositoryServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
fn default_branch_name(gb: &crate::bare::GitBare) -> String { fn default_branch_name(gb: &crate::bare::GitBare) -> String {
git_cmd(gb, &["symbolic-ref", "HEAD"]) git_cmd(gb, &["symbolic-ref", "HEAD"])
@@ -48,7 +30,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), false).await?
{
return client.get_repository(inner).await; return client.get_repository(inner).await;
} }
return Err(err); return Err(err);
@@ -95,11 +79,12 @@ impl repository_service_server::RepositoryService for GitksService {
let span = tracing::info_span!("repo.delete_repository", %repo); let span = tracing::info_span!("repo.delete_repository", %repo);
let _enter = span.enter(); let _enter = span.enter();
let bare_dir = self.resolve_for_init(inner.repository.as_ref())?; let bare_dir = self.resolve_for_init(inner.repository.as_ref())?;
if !bare_dir.exists() { if !bare_dir.exists()
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), true).await? { && let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), true).await?
{
return client.delete_repository(inner).await; return client.delete_repository(inner).await;
} }
}
tracing::warn!(%repo, path = %bare_dir.display(), "deleting repository"); tracing::warn!(%repo, path = %bare_dir.display(), "deleting repository");
std::fs::remove_dir_all(&bare_dir).map_err(|e| tonic::Status::internal(e.to_string()))?; std::fs::remove_dir_all(&bare_dir).map_err(|e| tonic::Status::internal(e.to_string()))?;
tracing::info!(%repo, "repository deleted"); tracing::info!(%repo, "repository deleted");
@@ -117,11 +102,12 @@ impl repository_service_server::RepositoryService for GitksService {
let _enter = span.enter(); let _enter = span.enter();
let bare_dir = self.resolve_for_init(inner.repository.as_ref())?; let bare_dir = self.resolve_for_init(inner.repository.as_ref())?;
let exists = bare_dir.exists() && bare_dir.is_dir() && bare_dir.join("HEAD").exists(); let exists = bare_dir.exists() && bare_dir.is_dir() && bare_dir.join("HEAD").exists();
if !exists { if !exists
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), false).await? { && let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), false).await?
{
return client.repository_exists(inner).await; return client.repository_exists(inner).await;
} }
}
Ok(tonic::Response::new(RepositoryExistsResponse { exists })) Ok(tonic::Response::new(RepositoryExistsResponse { exists }))
} }
@@ -136,7 +122,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), false).await?
{
return client.get_object_format(inner).await; return client.get_object_format(inner).await;
} }
return Err(err); return Err(err);
@@ -159,7 +147,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), false).await?
{
return client.get_default_branch(inner).await; return client.get_default_branch(inner).await;
} }
return Err(err); return Err(err);
@@ -183,7 +173,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), true).await?
{
return client.set_default_branch(inner).await; return client.set_default_branch(inner).await;
} }
return Err(err); return Err(err);
@@ -213,7 +205,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), false).await?
{
return client.get_repository_config(inner).await; return client.get_repository_config(inner).await;
} }
return Err(err); return Err(err);
@@ -238,6 +232,8 @@ impl repository_service_server::RepositoryService for GitksService {
} }
} else { } else {
for key in &inner.keys { for key in &inner.keys {
crate::sanitize::validate_config_key(key)
.map_err(|e| tonic::Status::invalid_argument(e.to_string()))?;
let out = git_cmd(&gb, &["config", "--get-all", key])?; let out = git_cmd(&gb, &["config", "--get-all", key])?;
if out.status.success() { if out.status.success() {
let vals: Vec<String> = String::from_utf8_lossy(&out.stdout) let vals: Vec<String> = String::from_utf8_lossy(&out.stdout)
@@ -270,7 +266,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), true).await?
{
return client.set_repository_config(inner).await; return client.set_repository_config(inner).await;
} }
return Err(err); return Err(err);
@@ -278,6 +276,8 @@ impl repository_service_server::RepositoryService for GitksService {
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
for entry in &inner.entries { for entry in &inner.entries {
crate::sanitize::validate_config_key(&entry.key)
.map_err(|e| tonic::Status::invalid_argument(e.to_string()))?;
if entry.values.is_empty() { if entry.values.is_empty() {
git_cmd(&gb, &["config", "--unset-all", &entry.key])?; git_cmd(&gb, &["config", "--unset-all", &entry.key])?;
} else { } else {
@@ -305,7 +305,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), false).await?
{
return client.get_repository_statistics(inner).await; return client.get_repository_statistics(inner).await;
} }
return Err(err); return Err(err);
@@ -326,7 +328,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), false).await?
{
return client.check_repository_health(inner).await; return client.check_repository_health(inner).await;
} }
return Err(err); return Err(err);
@@ -349,7 +353,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), true).await?
{
return client.garbage_collect(inner).await; return client.garbage_collect(inner).await;
} }
return Err(err); return Err(err);
@@ -372,7 +378,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), true).await?
{
return client.repack(inner).await; return client.repack(inner).await;
} }
return Err(err); return Err(err);
@@ -400,7 +408,9 @@ impl repository_service_server::RepositoryService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_repository_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_repository_client(self, inner.repository.as_ref(), true).await?
{
return client.write_commit_graph(inner).await; return client.write_commit_graph(inner).await;
} }
return Err(err); return Err(err);
+5 -8
View File
@@ -39,17 +39,14 @@ fn dir_size(gb: &crate::bare::GitBare) -> u64 {
} }
fn count_refs(gb: &crate::bare::GitBare) -> u64 { fn count_refs(gb: &crate::bare::GitBare) -> u64 {
let out = git_cmd(gb, &["for-each-ref", "--format=%(refname)"]).unwrap_or_else(|_| { let out = git_cmd(gb, &["for-each-ref", "--format=%(refname)"]).ok();
std::process::Output { out.map(|o| {
status: Default::default(), String::from_utf8_lossy(&o.stdout)
stdout: Vec::new(),
stderr: Vec::new(),
}
});
String::from_utf8_lossy(&out.stdout)
.lines() .lines()
.filter(|l| !l.is_empty()) .filter(|l| !l.is_empty())
.count() as u64 .count() as u64
})
.unwrap_or(0)
} }
fn file_len(path: &std::path::Path) -> u64 { fn file_len(path: &std::path::Path) -> u64 {
+17 -25
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::tag_service_client::TagServiceClient; use crate::pb::tag_service_client::TagServiceClient;
use crate::pb::*;
use super::{GitksService, into_status}; use super::{GitksService, into_status};
async fn remote_tag_client( remote_client!(remote_tag_client, TagServiceClient<tonic::transport::Channel>, "tag");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<TagServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding tag rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = TagServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl tag_service_server::TagService for GitksService { impl tag_service_server::TagService for GitksService {
@@ -36,7 +18,9 @@ impl tag_service_server::TagService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tag_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tag_client(self, inner.repository.as_ref(), false).await?
{
return client.list_tags(inner).await; return client.list_tags(inner).await;
} }
return Err(err); return Err(err);
@@ -60,7 +44,9 @@ impl tag_service_server::TagService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tag_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tag_client(self, inner.repository.as_ref(), false).await?
{
return client.get_tag(inner).await; return client.get_tag(inner).await;
} }
return Err(err); return Err(err);
@@ -83,7 +69,9 @@ impl tag_service_server::TagService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tag_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_tag_client(self, inner.repository.as_ref(), true).await?
{
return client.create_tag(inner).await; return client.create_tag(inner).await;
} }
return Err(err); return Err(err);
@@ -108,7 +96,9 @@ impl tag_service_server::TagService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tag_client(self, inner.repository.as_ref(), true).await? { if let Some(mut client) =
remote_tag_client(self, inner.repository.as_ref(), true).await?
{
return client.delete_tag(inner).await; return client.delete_tag(inner).await;
} }
return Err(err); return Err(err);
@@ -133,7 +123,9 @@ impl tag_service_server::TagService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tag_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tag_client(self, inner.repository.as_ref(), false).await?
{
return client.verify_tag(inner).await; return client.verify_tag(inner).await;
} }
return Err(err); return Err(err);
+20 -26
View File
@@ -1,27 +1,9 @@
use crate::pb::*;
use crate::pb::tree_service_client::TreeServiceClient; use crate::pb::tree_service_client::TreeServiceClient;
use crate::pb::*;
use super::{GitksService, cache, into_status, into_stream}; use super::{GitksService, cache, into_status, into_stream};
async fn remote_tree_client( remote_client!(remote_tree_client, TreeServiceClient<tonic::transport::Channel>, "tree");
svc: &GitksService,
header: Option<&RepositoryHeader>,
is_write: bool,
) -> Result<Option<TreeServiceClient<tonic::transport::Channel>>, tonic::Status> {
let header = match header {
Some(h) => h,
None => return Ok(None),
};
let Some(route) = svc.route_repository(header, is_write).await? else {
return Ok(None);
};
tracing::info!(storage_name = %route.storage_name, relative_path = %route.relative_path, actor_name = %route.actor_name, grpc_addr = %route.grpc_addr, "forwarding tree rpc");
let endpoint = super::remote_endpoint(&route.grpc_addr).await?;
let client = TreeServiceClient::connect(endpoint)
.await
.map_err(|e| tonic::Status::unavailable(e.to_string()))?;
Ok(Some(client))
}
#[tonic::async_trait] #[tonic::async_trait]
impl tree_service_server::TreeService for GitksService { impl tree_service_server::TreeService for GitksService {
@@ -39,7 +21,9 @@ impl tree_service_server::TreeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tree_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tree_client(self, inner.repository.as_ref(), false).await?
{
return client.list_tree(inner).await; return client.list_tree(inner).await;
} }
return Err(err); return Err(err);
@@ -68,7 +52,9 @@ impl tree_service_server::TreeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tree_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tree_client(self, inner.repository.as_ref(), false).await?
{
return client.get_tree(inner).await; return client.get_tree(inner).await;
} }
return Err(err); return Err(err);
@@ -97,7 +83,9 @@ impl tree_service_server::TreeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tree_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tree_client(self, inner.repository.as_ref(), false).await?
{
return client.get_blob(inner).await; return client.get_blob(inner).await;
} }
return Err(err); return Err(err);
@@ -125,7 +113,9 @@ impl tree_service_server::TreeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tree_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tree_client(self, inner.repository.as_ref(), false).await?
{
let resp = client.get_raw_blob(inner).await?; let resp = client.get_raw_blob(inner).await?;
let stream = super::bridge_server_stream(resp.into_inner()); let stream = super::bridge_server_stream(resp.into_inner());
return Ok(tonic::Response::new(stream)); return Ok(tonic::Response::new(stream));
@@ -159,7 +149,9 @@ impl tree_service_server::TreeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tree_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tree_client(self, inner.repository.as_ref(), false).await?
{
return client.get_file_metadata(inner).await; return client.get_file_metadata(inner).await;
} }
return Err(err); return Err(err);
@@ -187,7 +179,9 @@ impl tree_service_server::TreeService for GitksService {
let gb = match self.resolve(inner.repository.as_ref()) { let gb = match self.resolve(inner.repository.as_ref()) {
Ok(gb) => gb, Ok(gb) => gb,
Err(err) if err.code() == tonic::Code::NotFound => { Err(err) if err.code() == tonic::Code::NotFound => {
if let Some(mut client) = remote_tree_client(self, inner.repository.as_ref(), false).await? { if let Some(mut client) =
remote_tree_client(self, inner.repository.as_ref(), false).await?
{
return client.find_files(inner).await; return client.find_files(inner).await;
} }
return Err(err); return Err(err);
+5 -1
View File
@@ -4,9 +4,13 @@ use crate::pb::{CreateTagRequest, GetTagRequest, Tag};
impl GitBare { impl GitBare {
pub fn create_tag(&self, request: CreateTagRequest) -> GitResult<Tag> { pub fn create_tag(&self, request: CreateTagRequest) -> GitResult<Tag> {
crate::sanitize::validate_ref_name(&request.name)?;
let target = match request.target.and_then(|s| s.selector) { let target = match request.target.and_then(|s| s.selector) {
Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex,
Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, Some(crate::pb::object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
let mut args = vec![ let mut args = vec![
+6 -6
View File
@@ -13,7 +13,7 @@ fn hdr(name: &str) -> RepositoryHeader {
#[tokio::test] #[tokio::test]
async fn test_get_archive_tar() { async fn test_get_archive_tar() {
let (dir, gb) = common::setup_bare_repo(); let (dir, _gb) = common::setup_bare_repo();
let svc = common::setup_service(dir.path()); let svc = common::setup_service(dir.path());
let chunks = svc let chunks = svc
.get_archive(tonic::Request::new(ArchiveRequest { .get_archive(tonic::Request::new(ArchiveRequest {
@@ -40,7 +40,7 @@ async fn test_get_archive_tar() {
#[tokio::test] #[tokio::test]
async fn test_get_archive_zip() { async fn test_get_archive_zip() {
let (dir, gb) = common::setup_bare_repo(); let (dir, _gb) = common::setup_bare_repo();
let svc = common::setup_service(dir.path()); let svc = common::setup_service(dir.path());
let chunks = svc let chunks = svc
.get_archive(tonic::Request::new(ArchiveRequest { .get_archive(tonic::Request::new(ArchiveRequest {
@@ -70,7 +70,7 @@ async fn test_get_archive_zip() {
#[tokio::test] #[tokio::test]
async fn test_list_archive_entries() { async fn test_list_archive_entries() {
let (dir, gb) = common::setup_bare_repo(); let (dir, _gb) = common::setup_bare_repo();
let svc = common::setup_service(dir.path()); let svc = common::setup_service(dir.path());
let result = svc let result = svc
.list_archive_entries(tonic::Request::new(ListArchiveEntriesRequest { .list_archive_entries(tonic::Request::new(ListArchiveEntriesRequest {
@@ -98,7 +98,7 @@ async fn test_list_archive_entries() {
#[tokio::test] #[tokio::test]
async fn test_get_archive_with_prefix() { async fn test_get_archive_with_prefix() {
let (dir, gb) = common::setup_bare_repo(); let (dir, _gb) = common::setup_bare_repo();
let svc = common::setup_service(dir.path()); let svc = common::setup_service(dir.path());
let chunks = svc let chunks = svc
.get_archive(tonic::Request::new(ArchiveRequest { .get_archive(tonic::Request::new(ArchiveRequest {
@@ -124,7 +124,7 @@ async fn test_get_archive_with_prefix() {
#[tokio::test] #[tokio::test]
async fn test_fsck_clean_repo() { async fn test_fsck_clean_repo() {
let (dir, gb) = common::setup_bare_repo(); let (dir, _gb) = common::setup_bare_repo();
let svc = common::setup_service(dir.path()); let svc = common::setup_service(dir.path());
let result = svc let result = svc
.fsck(tonic::Request::new(FsckRequest { .fsck(tonic::Request::new(FsckRequest {
@@ -209,7 +209,7 @@ async fn test_list_packfiles() {
#[tokio::test] #[tokio::test]
async fn test_advertise_refs() { async fn test_advertise_refs() {
let (dir, gb) = common::setup_bare_repo(); let (dir, _gb) = common::setup_bare_repo();
let svc = common::setup_service(dir.path()); let svc = common::setup_service(dir.path());
let result = svc let result = svc
.advertise_refs(tonic::Request::new(AdvertiseRefsRequest { .advertise_refs(tonic::Request::new(AdvertiseRefsRequest {
+1 -1
View File
@@ -6,7 +6,7 @@ use gitks::pb::RepositoryHeader;
#[test] #[test]
fn test_from_header_valid() { fn test_from_header_valid() {
let (dir, gb) = common::setup_bare_repo(); let (_dir, gb) = common::setup_bare_repo();
let parent = gb.bare_dir.parent().unwrap().to_string_lossy().into_owned(); let parent = gb.bare_dir.parent().unwrap().to_string_lossy().into_owned();
let name = gb let name = gb
.bare_dir .bare_dir
+3 -3
View File
@@ -124,13 +124,13 @@ async fn test_blame_author_info() {
let hunk = &result.hunks[0]; let hunk = &result.hunks[0];
let commit = hunk.commit.as_ref().unwrap(); let commit = hunk.commit.as_ref().unwrap();
if let Some(ref author) = commit.author { if let Some(ref author) = commit.author
if let Some(ref id) = author.identity { && let Some(ref id) = author.identity
{
assert_eq!(id.name, "Test", "author name should match"); assert_eq!(id.name, "Test", "author name should match");
assert_eq!(id.email, "test@example.com"); assert_eq!(id.email, "test@example.com");
} }
} }
}
#[tokio::test] #[tokio::test]
async fn test_blame_nonexistent_file() { async fn test_blame_nonexistent_file() {
+2
View File
@@ -1,6 +1,7 @@
use gitks::bare::GitBare; use gitks::bare::GitBare;
use gitks::server::GitksService; use gitks::server::GitksService;
#[allow(dead_code)]
pub fn setup_service(dir: &std::path::Path) -> GitksService { pub fn setup_service(dir: &std::path::Path) -> GitksService {
GitksService::new(dir.to_path_buf()) GitksService::new(dir.to_path_buf())
} }
@@ -104,6 +105,7 @@ pub fn setup_bare_repo() -> (tempfile::TempDir, GitBare) {
(dir, GitBare::new(bare_dir)) (dir, GitBare::new(bare_dir))
} }
#[allow(dead_code)]
pub fn setup_bare_repo_with_conflict() -> (tempfile::TempDir, GitBare) { pub fn setup_bare_repo_with_conflict() -> (tempfile::TempDir, GitBare) {
let dir = tempfile::tempdir().expect("create temp dir"); let dir = tempfile::tempdir().expect("create temp dir");
let bare_dir = dir.path().join("test-repo"); let bare_dir = dir.path().join("test-repo");
+184
View File
@@ -0,0 +1,184 @@
use gitks::error::GitResult;
use gitks::pb::object_selector::Selector;
use gitks::pb::{ObjectName, ObjectSelector};
fn test_macro(selector: Option<ObjectSelector>) -> GitResult<String> {
let result = gitks::resolve_revision!(selector);
Ok(result)
}
fn test_macro_with_default(selector: Option<ObjectSelector>, default: &str) -> GitResult<String> {
let result = gitks::resolve_revision!(selector, default);
Ok(result)
}
#[test]
fn test_resolve_revision_with_oid() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Oid(gitks::pb::Oid {
hex: "abc1234567890123456789012345678901234567".to_string(),
value: vec![],
format: gitks::pb::ObjectFormat::Sha1 as i32,
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "abc1234567890123456789012345678901234567");
}
#[test]
fn test_resolve_revision_with_valid_revision() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "main".to_string(),
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "main");
}
#[test]
fn test_resolve_revision_with_valid_branch() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "feature/new-api".to_string(),
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "feature/new-api");
}
#[test]
fn test_resolve_revision_with_head() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "HEAD".to_string(),
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "HEAD");
}
#[test]
fn test_resolve_revision_with_ancestry() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "main~3".to_string(),
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "main~3");
}
#[test]
fn test_resolve_revision_none_defaults_to_head() {
let result = test_macro(None).unwrap();
assert_eq!(result, "HEAD");
}
#[test]
fn test_resolve_revision_with_custom_default() {
let result = test_macro_with_default(None, "develop").unwrap();
assert_eq!(result, "develop");
}
#[test]
fn test_resolve_revision_empty_selector() {
let selector = Some(ObjectSelector { selector: None });
let result = test_macro(selector).unwrap();
assert_eq!(result, "HEAD");
}
#[test]
fn test_resolve_revision_empty_with_custom_default() {
let selector = Some(ObjectSelector { selector: None });
let result = test_macro_with_default(selector, "custom-branch").unwrap();
assert_eq!(result, "custom-branch");
}
#[test]
fn test_resolve_revision_rejects_dangerous() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "branch;rm -rf /".to_string(),
})),
});
let result = test_macro(selector);
assert!(result.is_err());
}
#[test]
fn test_resolve_revision_rejects_traversal() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "../etc/passwd".to_string(),
})),
});
let result = test_macro(selector);
assert!(result.is_err());
}
#[test]
fn test_resolve_revision_rejects_excessive_depth() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "main~99999".to_string(),
})),
});
let result = test_macro(selector);
assert!(result.is_err());
}
#[test]
fn test_resolve_revision_rejects_too_long() {
let long_rev = "a".repeat(300);
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName { revision: long_rev })),
});
let result = test_macro(selector);
assert!(result.is_err());
}
#[test]
fn test_resolve_revision_accepts_valid_hex() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "deadbeef1234567890abcdef".to_string(),
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "deadbeef1234567890abcdef");
}
#[test]
fn test_resolve_revision_accepts_ref_prefix() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "ref:refs/heads/main".to_string(),
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "ref:refs/heads/main");
}
#[test]
fn test_resolve_revision_accepts_tree_suffix() {
let selector = Some(ObjectSelector {
selector: Some(Selector::Revision(ObjectName {
revision: "main^{tree}".to_string(),
})),
});
let result = test_macro(selector).unwrap();
assert_eq!(result, "main^{tree}");
}
+283
View File
@@ -0,0 +1,283 @@
use gitks::sanitize::*;
// ==================== validate_ref_name tests ====================
#[test]
fn test_validate_ref_name_accepts_valid_names() {
assert!(validate_ref_name("main").is_ok());
assert!(validate_ref_name("master").is_ok());
assert!(validate_ref_name("feature/new-api").is_ok());
assert!(validate_ref_name("hotfix/bug-fix-123").is_ok());
assert!(validate_ref_name("v1.0.0").is_ok());
assert!(validate_ref_name("release-2024").is_ok());
assert!(validate_ref_name("user/feature/branch").is_ok());
}
#[test]
fn test_validate_ref_name_rejects_empty() {
assert!(validate_ref_name("").is_err());
}
#[test]
fn test_validate_ref_name_rejects_dot_prefix_suffix() {
assert!(validate_ref_name(".branch").is_err());
assert!(validate_ref_name("branch.").is_err());
assert!(validate_ref_name(".branch.").is_err());
}
#[test]
fn test_validate_ref_name_rejects_slash_suffix() {
assert!(validate_ref_name("branch/").is_err());
}
#[test]
fn test_validate_ref_name_rejects_double_dot() {
assert!(validate_ref_name("feature..branch").is_err());
assert!(validate_ref_name("..branch").is_err());
assert!(validate_ref_name("branch..").is_err());
}
#[test]
fn test_validate_ref_name_rejects_at_brace() {
assert!(validate_ref_name("branch@{1}").is_err());
assert!(validate_ref_name("@{upstream}").is_err());
assert!(validate_ref_name("feature/@{branch}").is_err());
}
#[test]
fn test_validate_ref_name_rejects_forbidden_chars() {
assert!(validate_ref_name("branch~1").is_err());
assert!(validate_ref_name("branch^1").is_err());
assert!(validate_ref_name("branch:feature").is_err());
assert!(validate_ref_name("branch?query").is_err());
assert!(validate_ref_name("branch*glob").is_err());
assert!(validate_ref_name("branch[0]").is_err());
assert!(validate_ref_name("branch\\escape").is_err());
assert!(validate_ref_name("branch name").is_err());
assert!(validate_ref_name("branch\ttab").is_err());
assert!(validate_ref_name("branch\nnewline").is_err());
assert!(validate_ref_name("branch\rreturn").is_err());
assert!(validate_ref_name("branch\0null").is_err());
}
#[test]
fn test_validate_ref_name_rejects_too_long() {
let long_name = "a".repeat(256);
assert!(validate_ref_name(&long_name).is_err());
let max_valid_name = "a".repeat(255);
assert!(validate_ref_name(&max_valid_name).is_ok());
}
// ==================== validate_revision tests ====================
#[test]
fn test_validate_revision_accepts_empty() {
assert!(validate_revision("").is_err());
}
#[test]
fn test_validate_revision_accepts_head() {
assert!(validate_revision("HEAD").is_ok());
}
#[test]
fn test_validate_revision_accepts_valid_hex() {
assert!(validate_revision("abc1234").is_ok());
assert!(validate_revision("abc1234567890123456789012345678901234567890").is_ok());
assert!(validate_revision("deadbeef").is_ok());
}
#[test]
fn test_validate_revision_rejects_invalid_hex_length() {
// "abc" is 3 chars - too short to be hex OID (requires 4-64), but valid as branch name
assert!(validate_revision("abc").is_ok());
let too_long = "a".repeat(65);
// 65 hex chars - too long to be hex OID, but might be valid as branch name
// However, it will fail ref name length check (> 255 chars would fail, but 65 is fine)
// Actually 65 chars of 'a' is a valid branch name, so it should pass
assert!(validate_revision(&too_long).is_ok());
}
#[test]
fn test_validate_revision_accepts_ref_prefix() {
assert!(validate_revision("ref:refs/heads/main").is_ok());
assert!(validate_revision("ref:refs/tags/v1.0.0").is_ok());
}
#[test]
fn test_validate_revision_accepts_ancestry_operators() {
assert!(validate_revision("main~1").is_ok());
assert!(validate_revision("main~10").is_ok());
assert!(validate_revision("HEAD~10000").is_ok());
assert!(validate_revision("main^1").is_ok());
assert!(validate_revision("main^2").is_ok());
assert!(validate_revision("HEAD^10000").is_ok());
}
#[test]
fn test_validate_revision_rejects_excessive_depth() {
assert!(validate_revision("main~10001").is_err());
assert!(validate_revision("main~999999999999").is_err());
assert!(validate_revision("main^10001").is_err());
assert!(validate_revision("main^999999999999").is_err());
}
#[test]
fn test_validate_revision_accepts_tree_suffix() {
assert!(validate_revision("main^{tree}").is_ok());
assert!(validate_revision("HEAD^{tree}").is_ok());
}
#[test]
fn test_validate_revision_rejects_too_long() {
// Test length limit (256 chars) - use a simple branch name to avoid depth checks
let long_rev = "a".repeat(257);
assert!(validate_revision(&long_rev).is_err());
// For non-hex revisions, the effective limit is 255 chars (ref name limit)
let max_valid = "a".repeat(255);
assert!(validate_revision(&max_valid).is_ok());
}
#[test]
fn test_validate_revision_accepts_valid_branch_names() {
assert!(validate_revision("main").is_ok());
assert!(validate_revision("feature/new-api").is_ok());
// v1.0.0 contains dots but they're not at start/end, so it's valid
assert!(validate_revision("v1.0.0").is_ok());
}
// ==================== validate_file_path tests ====================
#[test]
fn test_validate_file_path_accepts_valid_paths() {
assert!(validate_file_path("file.txt").is_ok());
assert!(validate_file_path("src/main.rs").is_ok());
assert!(validate_file_path("deep/nested/path/file.js").is_ok());
assert!(validate_file_path("README.md").is_ok());
}
#[test]
fn test_validate_file_path_rejects_empty() {
assert!(validate_file_path("").is_err());
}
#[test]
fn test_validate_file_path_rejects_absolute_paths() {
assert!(validate_file_path("/etc/passwd").is_err());
assert!(validate_file_path("/absolute/path").is_err());
assert!(validate_file_path("/file.txt").is_err());
}
#[test]
fn test_validate_file_path_rejects_path_traversal() {
assert!(validate_file_path("../escape").is_err());
assert!(validate_file_path("path/../escape").is_err());
assert!(validate_file_path("path/../../escape").is_err());
assert!(validate_file_path("..").is_err());
}
#[test]
fn test_validate_file_path_rejects_null_bytes() {
assert!(validate_file_path("file\0.txt").is_err());
assert!(validate_file_path("path/\0escape").is_err());
}
#[test]
fn test_validate_file_path_rejects_git_directory() {
assert!(validate_file_path(".git").is_err());
assert!(validate_file_path(".git/config").is_err());
assert!(validate_file_path(".git/hooks/pre-commit").is_err());
assert!(validate_file_path("path/.git/config").is_err());
assert!(validate_file_path(".git/").is_err());
}
#[test]
fn test_validate_file_path_rejects_too_long() {
let long_path = "a".repeat(4097);
assert!(validate_file_path(&long_path).is_err());
let max_valid_path = "a".repeat(4096);
assert!(validate_file_path(&max_valid_path).is_ok());
}
#[cfg(windows)]
#[test]
fn test_validate_file_path_rejects_windows_reserved_names() {
assert!(validate_file_path("CON").is_err());
assert!(validate_file_path("PRN").is_err());
assert!(validate_file_path("AUX").is_err());
assert!(validate_file_path("NUL").is_err());
assert!(validate_file_path("COM1").is_err());
assert!(validate_file_path("LPT1").is_err());
assert!(validate_file_path("path/CON").is_err());
assert!(validate_file_path("CON.txt").is_err());
}
// ==================== validate_relative_path tests ====================
#[test]
fn test_validate_relative_path_accepts_valid_paths() {
assert!(validate_relative_path("repo").is_ok());
assert!(validate_relative_path("path/to/repo").is_ok());
assert!(validate_relative_path("user/project").is_ok());
}
#[test]
fn test_validate_relative_path_rejects_empty() {
assert!(validate_relative_path("").is_err());
}
#[test]
fn test_validate_relative_path_rejects_absolute() {
assert!(validate_relative_path("/absolute/path").is_err());
assert!(validate_relative_path("/etc").is_err());
}
#[test]
fn test_validate_relative_path_rejects_traversal() {
assert!(validate_relative_path("../escape").is_err());
assert!(validate_relative_path("path/../escape").is_err());
assert!(validate_relative_path("..").is_err());
assert!(validate_relative_path("path/..").is_err());
}
// ==================== validate_config_key tests ====================
#[test]
fn test_validate_config_key_accepts_safe_keys() {
assert!(validate_config_key("user.name").is_ok());
assert!(validate_config_key("user.email").is_ok());
assert!(validate_config_key("core.editor").is_ok());
assert!(validate_config_key("alias.co").is_ok());
}
#[test]
fn test_validate_config_key_rejects_empty() {
assert!(validate_config_key("").is_err());
}
#[test]
fn test_validate_config_key_rejects_dangerous_keys() {
assert!(validate_config_key("core.sshCommand").is_err());
assert!(validate_config_key("core.hooksPath").is_err());
assert!(validate_config_key("safe.directory").is_err());
}
#[test]
fn test_validate_config_key_rejects_wildcard_dangerous_keys() {
assert!(validate_config_key("remote.origin.url").is_err());
assert!(validate_config_key("remote.upstream.url").is_err());
assert!(validate_config_key("http.proxy").is_err());
assert!(validate_config_key("https.proxy").is_err());
}
#[test]
fn test_validate_config_key_rejects_invalid_chars() {
assert!(validate_config_key("key with space").is_err());
assert!(validate_config_key("key;rm -rf").is_err());
assert!(validate_config_key("key$(command)").is_err());
assert!(validate_config_key("key`command`").is_err());
}
+3 -6
View File
@@ -2,17 +2,14 @@ use gix::object::tree::EntryKind;
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::{FileMetadata, GetFileMetadataRequest, ObjectType, object_selector}; use crate::pb::{FileMetadata, GetFileMetadataRequest, ObjectType};
use crate::resolve_revision;
use crate::tree; use crate::tree;
impl GitBare { impl GitBare {
pub fn get_file_metadata(&self, request: GetFileMetadataRequest) -> GitResult<FileMetadata> { pub fn get_file_metadata(&self, request: GetFileMetadataRequest) -> GitResult<FileMetadata> {
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let revision = match request.revision.and_then(|s| s.selector) { let revision = resolve_revision!(request.revision);
Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision,
None => "HEAD".into(),
};
let tree = repo let tree = repo
.rev_parse_single(format!("{}^{{tree}}", revision).as_str())? .rev_parse_single(format!("{}^{{tree}}", revision).as_str())?
.object()? .object()?
+3 -9
View File
@@ -1,25 +1,19 @@
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::error::{GitError, GitResult}; use crate::error::{GitError, GitResult};
use crate::pb::{GetTreeRequest, ListTreeRequest, Tree}; use crate::pb::{GetTreeRequest, ListTreeRequest, Tree};
use crate::resolve_revision;
impl GitBare { impl GitBare {
pub fn get_tree(&self, request: GetTreeRequest) -> GitResult<Tree> { pub fn get_tree(&self, request: GetTreeRequest) -> GitResult<Tree> {
let entries = self.list_tree(ListTreeRequest { let entries = self.list_tree(ListTreeRequest {
repository: request.repository, repository: request.repository.clone(),
revision: request.revision.clone(), revision: request.revision.clone(),
path: request.path.clone(), path: request.path.clone(),
recursive: false, recursive: false,
pagination: None, pagination: None,
})?; })?;
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let revision = request let revision = resolve_revision!(request.revision);
.revision
.and_then(|s| s.selector)
.map(|s| match s {
crate::pb::object_selector::Selector::Oid(oid) => oid.hex,
crate::pb::object_selector::Selector::Revision(name) => name.revision,
})
.unwrap_or_else(|| "HEAD".into());
let root = repo let root = repo
.rev_parse_single(format!("{}^{{tree}}", revision).as_str())? .rev_parse_single(format!("{}^{{tree}}", revision).as_str())?
.object()? .object()?
+4 -1
View File
@@ -10,7 +10,10 @@ impl GitBare {
let repo = self.gix_repo()?; let repo = self.gix_repo()?;
let revision = match request.revision.clone().and_then(|s| s.selector) { let revision = match request.revision.clone().and_then(|s| s.selector) {
Some(object_selector::Selector::Oid(oid)) => oid.hex, Some(object_selector::Selector::Oid(oid)) => oid.hex,
Some(object_selector::Selector::Revision(name)) => name.revision, Some(object_selector::Selector::Revision(name)) => {
crate::sanitize::validate_revision(&name.revision)?;
name.revision
}
None => "HEAD".into(), None => "HEAD".into(),
}; };
let mut tree = repo let mut tree = repo
+9 -4
View File
@@ -6,11 +6,16 @@ pub mod list_tree;
use crate::bare::GitBare; use crate::bare::GitBare;
use crate::pb::{self, RecentCommit, object_selector}; use crate::pb::{self, RecentCommit, object_selector};
pub(crate) fn resolve_revision(sel: &Option<pb::ObjectSelector>) -> String { pub(crate) fn resolve_revision(
sel: &Option<pb::ObjectSelector>,
) -> Result<String, crate::error::GitError> {
match sel.as_ref().and_then(|s| s.selector.as_ref()) { match sel.as_ref().and_then(|s| s.selector.as_ref()) {
Some(object_selector::Selector::Oid(oid)) => oid.hex.clone(), Some(object_selector::Selector::Oid(oid)) => Ok(oid.hex.clone()),
Some(object_selector::Selector::Revision(name)) => name.revision.clone(), Some(object_selector::Selector::Revision(name)) => {
None => "HEAD".into(), crate::sanitize::validate_revision(&name.revision)?;
Ok(name.revision.clone())
}
None => Ok("HEAD".into()),
} }
} }