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:
Generated
+44
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()))?;
|
||||||
|
|||||||
@@ -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()];
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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()?
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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())),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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!(
|
||||||
|
|||||||
@@ -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
@@ -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())),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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![
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user