| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | |
| 19 | |
| 20 | |
| 21 | |
| 22 | |
| 23 | |
| 24 | |
| 25 | |
| 26 | |
| 27 | |
| 28 | |
| 29 | |
| 30 | mod attributes; |
| 31 | pub mod merge; |
| 32 | pub mod mirror; |
| 33 | pub mod pack; |
| 34 | mod pubkey; |
| 35 | mod repo; |
| 36 | mod signature; |
| 37 | mod types; |
| 38 | |
| 39 | use std::fs; |
| 40 | use std::io; |
| 41 | use std::os::unix::fs::PermissionsExt; |
| 42 | use std::path::{Path, PathBuf}; |
| 43 | use std::process::Command; |
| 44 | |
| 45 | use git2::{Repository, RepositoryInitOptions}; |
| 46 | |
| 47 | pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet}; |
| 48 | pub use crate::pubkey::{ParsedKey, parse_public_key}; |
| 49 | pub use crate::repo::Repo; |
| 50 | pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_challenge}; |
| 51 | pub use crate::types::{ |
| 52 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, |
| 53 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, |
| 54 | TreeAnnotations, TreeEntry, |
| 55 | }; |
| 56 | |
| 57 | |
| 58 | #[derive(Debug, thiserror::Error)] |
| 59 | pub enum GitError { |
| 60 | |
| 61 | #[error(transparent)] |
| 62 | Libgit2(#[from] git2::Error), |
| 63 | |
| 64 | |
| 65 | #[error("git io error at {path}: {source}")] |
| 66 | Io { |
| 67 | |
| 68 | path: PathBuf, |
| 69 | |
| 70 | source: io::Error, |
| 71 | }, |
| 72 | |
| 73 | |
| 74 | #[error("repository id {0:?} is too short")] |
| 75 | BadId(String), |
| 76 | |
| 77 | |
| 78 | #[error("revision {0:?} not found")] |
| 79 | RevNotFound(String), |
| 80 | |
| 81 | |
| 82 | #[error("path {0:?} not found")] |
| 83 | PathNotFound(String), |
| 84 | |
| 85 | |
| 86 | #[error("path {0:?} is not a file")] |
| 87 | NotAFile(String), |
| 88 | |
| 89 | |
| 90 | #[error("invalid {kind} public key: {reason}")] |
| 91 | BadPublicKey { |
| 92 | |
| 93 | kind: model::KeyKind, |
| 94 | |
| 95 | reason: String, |
| 96 | }, |
| 97 | |
| 98 | |
| 99 | |
| 100 | #[error("merge failed: {0}")] |
| 101 | Merge(String), |
| 102 | |
| 103 | |
| 104 | |
| 105 | |
| 106 | #[error("merge has conflicts: {0}")] |
| 107 | MergeConflict(String), |
| 108 | |
| 109 | |
| 110 | #[error("git init failed: {0}")] |
| 111 | Init(String), |
| 112 | } |
| 113 | |
| 114 | |
| 115 | |
| 116 | |
| 117 | |
| 118 | |
| 119 | |
| 120 | pub fn repo_path(root: &Path, id: &str) -> Result<PathBuf, GitError> { |
| 121 | if id.len() < 2 { |
| 122 | return Err(GitError::BadId(id.to_string())); |
| 123 | } |
| 124 | Ok(root.join(&id[0..2]).join(format!("{id}.git"))) |
| 125 | } |
| 126 | |
| 127 | |
| 128 | |
| 129 | |
| 130 | |
| 131 | |
| 132 | |
| 133 | |
| 134 | |
| 135 | |
| 136 | |
| 137 | |
| 138 | |
| 139 | |
| 140 | |
| 141 | |
| 142 | pub fn create_bare( |
| 143 | root: &Path, |
| 144 | id: &str, |
| 145 | default_branch: &str, |
| 146 | hook_binary: &Path, |
| 147 | ) -> Result<Repo, GitError> { |
| 148 | let path = repo_path(root, id)?; |
| 149 | if path.exists() { |
| 150 | return Err(GitError::Io { |
| 151 | path: path.clone(), |
| 152 | source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"), |
| 153 | }); |
| 154 | } |
| 155 | if let Some(parent) = path.parent() { |
| 156 | fs::create_dir_all(parent).map_err(|source| GitError::Io { |
| 157 | path: parent.to_path_buf(), |
| 158 | source, |
| 159 | })?; |
| 160 | } |
| 161 | |
| 162 | |
| 163 | match init_inner(&path, default_branch, hook_binary) { |
| 164 | Ok(()) => Repo::open_path(&path), |
| 165 | Err(err) => { |
| 166 | let _ = fs::remove_dir_all(&path); |
| 167 | Err(err) |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | |
| 173 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 174 | pub enum ObjectFormat { |
| 175 | |
| 176 | #[default] |
| 177 | Sha1, |
| 178 | |
| 179 | Sha256, |
| 180 | } |
| 181 | |
| 182 | impl ObjectFormat { |
| 183 | |
| 184 | #[must_use] |
| 185 | pub fn as_str(self) -> &'static str { |
| 186 | match self { |
| 187 | Self::Sha1 => "sha1", |
| 188 | Self::Sha256 => "sha256", |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | |
| 193 | #[must_use] |
| 194 | pub fn from_token(token: &str) -> Self { |
| 195 | if token.eq_ignore_ascii_case("sha256") { |
| 196 | Self::Sha256 |
| 197 | } else { |
| 198 | Self::Sha1 |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | |
| 204 | |
| 205 | |
| 206 | |
| 207 | |
| 208 | |
| 209 | |
| 210 | |
| 211 | |
| 212 | |
| 213 | |
| 214 | |
| 215 | pub fn create_bare_with_format( |
| 216 | root: &Path, |
| 217 | id: &str, |
| 218 | default_branch: &str, |
| 219 | hook_binary: &Path, |
| 220 | format: ObjectFormat, |
| 221 | ) -> Result<(), GitError> { |
| 222 | match format { |
| 223 | ObjectFormat::Sha1 => create_bare(root, id, default_branch, hook_binary).map(|_| ()), |
| 224 | ObjectFormat::Sha256 => create_bare_sha256(root, id, default_branch, hook_binary), |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | |
| 229 | |
| 230 | fn create_bare_sha256( |
| 231 | root: &Path, |
| 232 | id: &str, |
| 233 | default_branch: &str, |
| 234 | hook_binary: &Path, |
| 235 | ) -> Result<(), GitError> { |
| 236 | let path = repo_path(root, id)?; |
| 237 | if path.exists() { |
| 238 | return Err(GitError::Io { |
| 239 | path: path.clone(), |
| 240 | source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"), |
| 241 | }); |
| 242 | } |
| 243 | if let Some(parent) = path.parent() { |
| 244 | fs::create_dir_all(parent).map_err(|source| GitError::Io { |
| 245 | path: parent.to_path_buf(), |
| 246 | source, |
| 247 | })?; |
| 248 | } |
| 249 | match init_sha256_inner(&path, default_branch, hook_binary) { |
| 250 | Ok(()) => Ok(()), |
| 251 | Err(err) => { |
| 252 | let _ = fs::remove_dir_all(&path); |
| 253 | Err(err) |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | |
| 259 | fn init_sha256_inner( |
| 260 | path: &Path, |
| 261 | default_branch: &str, |
| 262 | hook_binary: &Path, |
| 263 | ) -> Result<(), GitError> { |
| 264 | let run = |args: &[&str]| -> Result<(), GitError> { |
| 265 | let out = Command::new("git") |
| 266 | .args(args) |
| 267 | .output() |
| 268 | .map_err(|source| GitError::Io { |
| 269 | path: path.to_path_buf(), |
| 270 | source, |
| 271 | })?; |
| 272 | if out.status.success() { |
| 273 | Ok(()) |
| 274 | } else { |
| 275 | Err(GitError::Init( |
| 276 | String::from_utf8_lossy(&out.stderr).trim().to_string(), |
| 277 | )) |
| 278 | } |
| 279 | }; |
| 280 | let path_str = path.to_string_lossy(); |
| 281 | run(&[ |
| 282 | "init", |
| 283 | "--bare", |
| 284 | "--object-format=sha256", |
| 285 | "--initial-branch", |
| 286 | default_branch, |
| 287 | &path_str, |
| 288 | ])?; |
| 289 | |
| 290 | for (key, value) in [ |
| 291 | ("core.logallrefupdates", "true"), |
| 292 | ("receive.denyNonFastForwards", "false"), |
| 293 | ("gc.auto", "0"), |
| 294 | ] { |
| 295 | run(&["--git-dir", &path_str, "config", key, value])?; |
| 296 | } |
| 297 | install_hooks(path, hook_binary)?; |
| 298 | Ok(()) |
| 299 | } |
| 300 | |
| 301 | |
| 302 | |
| 303 | fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> { |
| 304 | let mut opts = RepositoryInitOptions::new(); |
| 305 | opts.bare(true).initial_head(default_branch).mkpath(true); |
| 306 | let repo = Repository::init_opts(path, &opts)?; |
| 307 | |
| 308 | let mut config = repo.config()?; |
| 309 | config.set_bool("core.logallrefupdates", true)?; |
| 310 | config.set_bool("receive.denyNonFastForwards", false)?; |
| 311 | config.set_i32("gc.auto", 0)?; |
| 312 | |
| 313 | install_hooks(path, hook_binary)?; |
| 314 | Ok(()) |
| 315 | } |
| 316 | |
| 317 | |
| 318 | |
| 319 | |
| 320 | fn install_hooks(repo_path: &Path, hook_binary: &Path) -> Result<(), GitError> { |
| 321 | let hooks_dir = repo_path.join("hooks"); |
| 322 | fs::create_dir_all(&hooks_dir).map_err(|source| GitError::Io { |
| 323 | path: hooks_dir.clone(), |
| 324 | source, |
| 325 | })?; |
| 326 | |
| 327 | let binary = hook_binary.display(); |
| 328 | write_hook( |
| 329 | &hooks_dir.join("post-receive"), |
| 330 | &format!("#!/bin/sh\nexec \"{binary}\" hook post-receive\n"), |
| 331 | )?; |
| 332 | |
| 333 | write_hook(&hooks_dir.join("pre-receive"), "#!/bin/sh\nexit 0\n")?; |
| 334 | Ok(()) |
| 335 | } |
| 336 | |
| 337 | |
| 338 | fn write_hook(path: &Path, contents: &str) -> Result<(), GitError> { |
| 339 | fs::write(path, contents).map_err(|source| GitError::Io { |
| 340 | path: path.to_path_buf(), |
| 341 | source, |
| 342 | })?; |
| 343 | fs::set_permissions(path, fs::Permissions::from_mode(0o755)).map_err(|source| { |
| 344 | GitError::Io { |
| 345 | path: path.to_path_buf(), |
| 346 | source, |
| 347 | } |
| 348 | })?; |
| 349 | Ok(()) |
| 350 | } |
| 351 | |
| 352 | #[cfg(test)] |
| 353 | mod tests { |
| 354 | #![allow(clippy::unwrap_used)] |
| 355 | |
| 356 | use super::*; |
| 357 | |
| 358 | #[test] |
| 359 | fn repo_path_shards_by_id_prefix() { |
| 360 | let root = Path::new("/srv/repos"); |
| 361 | let path = repo_path(root, "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap(); |
| 362 | assert_eq!( |
| 363 | path, |
| 364 | Path::new("/srv/repos/01/01hzxk9m2n8p7q6r5s4t3v2w1x.git") |
| 365 | ); |
| 366 | } |
| 367 | |
| 368 | #[test] |
| 369 | fn repo_path_rejects_short_ids() { |
| 370 | assert!(matches!( |
| 371 | repo_path(Path::new("/srv"), "a"), |
| 372 | Err(GitError::BadId(_)) |
| 373 | )); |
| 374 | } |
| 375 | |
| 376 | #[test] |
| 377 | fn create_bare_sets_head_config_and_hooks() { |
| 378 | let dir = tempfile::tempdir().unwrap(); |
| 379 | let repo = create_bare( |
| 380 | dir.path(), |
| 381 | "01hzxk9m2n8p7q6r5s4t3v2w1x", |
| 382 | "trunk", |
| 383 | Path::new("/usr/bin/fabrica"), |
| 384 | ) |
| 385 | .unwrap(); |
| 386 | |
| 387 | |
| 388 | assert_eq!(repo.head_branch().unwrap().as_deref(), Some("trunk")); |
| 389 | |
| 390 | let path = repo_path(dir.path(), "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap(); |
| 391 | |
| 392 | let hook = path.join("hooks").join("post-receive"); |
| 393 | let body = std::fs::read_to_string(&hook).unwrap(); |
| 394 | assert!(body.contains("hook post-receive"), "body: {body}"); |
| 395 | let mode = std::fs::metadata(&hook).unwrap().permissions().mode(); |
| 396 | assert_eq!(mode & 0o111, 0o111, "hook must be executable"); |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn create_bare_is_not_clobbering() { |
| 401 | let dir = tempfile::tempdir().unwrap(); |
| 402 | let id = "01hzxk9m2n8p7q6r5s4t3v2w1x"; |
| 403 | create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap(); |
| 404 | |
| 405 | assert!(create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).is_err()); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn create_bare_sha256_uses_the_requested_format() { |
| 410 | |
| 411 | if Command::new("git").arg("--version").output().is_err() { |
| 412 | return; |
| 413 | } |
| 414 | let dir = tempfile::tempdir().unwrap(); |
| 415 | let id = "01hzxk9m2n8p7q6r5s4t3v2w1y"; |
| 416 | create_bare_with_format( |
| 417 | dir.path(), |
| 418 | id, |
| 419 | "main", |
| 420 | Path::new("/usr/bin/fabrica"), |
| 421 | ObjectFormat::Sha256, |
| 422 | ) |
| 423 | .unwrap(); |
| 424 | |
| 425 | let path = repo_path(dir.path(), id).unwrap(); |
| 426 | |
| 427 | let out = Command::new("git") |
| 428 | .args(["--git-dir"]) |
| 429 | .arg(&path) |
| 430 | .args(["rev-parse", "--show-object-format"]) |
| 431 | .output() |
| 432 | .unwrap(); |
| 433 | assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256"); |
| 434 | |
| 435 | assert!(path.join("hooks").join("post-receive").exists()); |
| 436 | } |
| 437 | } |