Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/auth/src/access.rs +1 −0
| @@ -147,6 +147,7 @@ mod tests { | |||
| 147 | 147 | description: None, | |
| 148 | 148 | visibility, | |
| 149 | 149 | default_branch: "main".to_string(), | |
| 150 | + | object_format: "sha1".to_string(), | |
| 150 | 151 | issues_enabled: true, | |
| 151 | 152 | pulls_enabled: true, | |
| 152 | 153 | next_iid: 1, | |
crates/git/src/lib.rs +164 −0
| @@ -39,6 +39,7 @@ use std::fs; | |||
| 39 | 39 | use std::io; | |
| 40 | 40 | use std::os::unix::fs::PermissionsExt; | |
| 41 | 41 | use std::path::{Path, PathBuf}; | |
| 42 | + | use std::process::Command; | |
| 42 | 43 | ||
| 43 | 44 | use git2::{Repository, RepositoryInitOptions}; | |
| 44 | 45 | ||
| @@ -102,6 +103,10 @@ pub enum GitError { | |||
| 102 | 103 | /// rebase replay hit conflicts. | |
| 103 | 104 | #[error("merge has conflicts")] | |
| 104 | 105 | MergeConflict, | |
| 106 | + | ||
| 107 | + | /// `git init` (used for SHA-256 repositories) failed. | |
| 108 | + | #[error("git init failed: {0}")] | |
| 109 | + | Init(String), | |
| 105 | 110 | } | |
| 106 | 111 | ||
| 107 | 112 | /// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`. | |
| @@ -162,6 +167,135 @@ pub fn create_bare( | |||
| 162 | 167 | } | |
| 163 | 168 | } | |
| 164 | 169 | ||
| 170 | + | /// The hash algorithm a repository's objects are named with. | |
| 171 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 172 | + | pub enum ObjectFormat { | |
| 173 | + | /// SHA-1 (the historical default). | |
| 174 | + | #[default] | |
| 175 | + | Sha1, | |
| 176 | + | /// SHA-256 (git's newer, collision-resistant format). | |
| 177 | + | Sha256, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | impl ObjectFormat { | |
| 181 | + | /// The stable token stored on the repo row and passed to `git init`. | |
| 182 | + | #[must_use] | |
| 183 | + | pub fn as_str(self) -> &'static str { | |
| 184 | + | match self { | |
| 185 | + | Self::Sha1 => "sha1", | |
| 186 | + | Self::Sha256 => "sha256", | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /// Parse a stored/submitted token, defaulting to SHA-1. | |
| 191 | + | #[must_use] | |
| 192 | + | pub fn from_token(token: &str) -> Self { | |
| 193 | + | if token.eq_ignore_ascii_case("sha256") { | |
| 194 | + | Self::Sha256 | |
| 195 | + | } else { | |
| 196 | + | Self::Sha1 | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Create a bare repository with a chosen object format. | |
| 202 | + | /// | |
| 203 | + | /// SHA-1 uses the in-process [`create_bare`]. SHA-256 is created by the `git` | |
| 204 | + | /// binary (`git init --object-format=sha256`), because libgit2's SHA-256 support | |
| 205 | + | /// is experimental and often unavailable — the resulting repository is still a | |
| 206 | + | /// fully valid git repo served over the pack transport, though in-process | |
| 207 | + | /// browsing depends on the local libgit2 build. | |
| 208 | + | /// | |
| 209 | + | /// # Errors | |
| 210 | + | /// | |
| 211 | + | /// Returns [`GitError`] if the id is invalid, the directory already exists, or a | |
| 212 | + | /// git/filesystem step fails. | |
| 213 | + | pub fn create_bare_with_format( | |
| 214 | + | root: &Path, | |
| 215 | + | id: &str, | |
| 216 | + | default_branch: &str, | |
| 217 | + | hook_binary: &Path, | |
| 218 | + | format: ObjectFormat, | |
| 219 | + | ) -> Result<(), GitError> { | |
| 220 | + | match format { | |
| 221 | + | ObjectFormat::Sha1 => create_bare(root, id, default_branch, hook_binary).map(|_| ()), | |
| 222 | + | ObjectFormat::Sha256 => create_bare_sha256(root, id, default_branch, hook_binary), | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// Create a SHA-256 bare repository via the `git` binary, then apply the same | |
| 227 | + | /// server config and hooks [`create_bare`] installs. | |
| 228 | + | fn create_bare_sha256( | |
| 229 | + | root: &Path, | |
| 230 | + | id: &str, | |
| 231 | + | default_branch: &str, | |
| 232 | + | hook_binary: &Path, | |
| 233 | + | ) -> Result<(), GitError> { | |
| 234 | + | let path = repo_path(root, id)?; | |
| 235 | + | if path.exists() { | |
| 236 | + | return Err(GitError::Io { | |
| 237 | + | path: path.clone(), | |
| 238 | + | source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"), | |
| 239 | + | }); | |
| 240 | + | } | |
| 241 | + | if let Some(parent) = path.parent() { | |
| 242 | + | fs::create_dir_all(parent).map_err(|source| GitError::Io { | |
| 243 | + | path: parent.to_path_buf(), | |
| 244 | + | source, | |
| 245 | + | })?; | |
| 246 | + | } | |
| 247 | + | match init_sha256_inner(&path, default_branch, hook_binary) { | |
| 248 | + | Ok(()) => Ok(()), | |
| 249 | + | Err(err) => { | |
| 250 | + | let _ = fs::remove_dir_all(&path); | |
| 251 | + | Err(err) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// The fallible body of [`create_bare_sha256`]. | |
| 257 | + | fn init_sha256_inner( | |
| 258 | + | path: &Path, | |
| 259 | + | default_branch: &str, | |
| 260 | + | hook_binary: &Path, | |
| 261 | + | ) -> Result<(), GitError> { | |
| 262 | + | let run = |args: &[&str]| -> Result<(), GitError> { | |
| 263 | + | let out = Command::new("git") | |
| 264 | + | .args(args) | |
| 265 | + | .output() | |
| 266 | + | .map_err(|source| GitError::Io { | |
| 267 | + | path: path.to_path_buf(), | |
| 268 | + | source, | |
| 269 | + | })?; | |
| 270 | + | if out.status.success() { | |
| 271 | + | Ok(()) | |
| 272 | + | } else { | |
| 273 | + | Err(GitError::Init( | |
| 274 | + | String::from_utf8_lossy(&out.stderr).trim().to_string(), | |
| 275 | + | )) | |
| 276 | + | } | |
| 277 | + | }; | |
| 278 | + | let path_str = path.to_string_lossy(); | |
| 279 | + | run(&[ | |
| 280 | + | "init", | |
| 281 | + | "--bare", | |
| 282 | + | "--object-format=sha256", | |
| 283 | + | "--initial-branch", | |
| 284 | + | default_branch, | |
| 285 | + | &path_str, | |
| 286 | + | ])?; | |
| 287 | + | // Mirror the server config `create_bare` sets (§3.2). | |
| 288 | + | for (key, value) in [ | |
| 289 | + | ("core.logallrefupdates", "true"), | |
| 290 | + | ("receive.denyNonFastForwards", "false"), | |
| 291 | + | ("gc.auto", "0"), | |
| 292 | + | ] { | |
| 293 | + | run(&["--git-dir", &path_str, "config", key, value])?; | |
| 294 | + | } | |
| 295 | + | install_hooks(path, hook_binary)?; | |
| 296 | + | Ok(()) | |
| 297 | + | } | |
| 298 | + | ||
| 165 | 299 | /// The fallible body of [`create_bare`], separated so the caller can clean up on | |
| 166 | 300 | /// any error. | |
| 167 | 301 | fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> { | |
| @@ -268,4 +402,34 @@ mod tests { | |||
| 268 | 402 | // A second create at the same id fails (the directory already exists). | |
| 269 | 403 | assert!(create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).is_err()); | |
| 270 | 404 | } | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn create_bare_sha256_uses_the_requested_format() { | |
| 408 | + | // SHA-256 creation shells out to `git`; skip cleanly where it is absent. | |
| 409 | + | if Command::new("git").arg("--version").output().is_err() { | |
| 410 | + | return; | |
| 411 | + | } | |
| 412 | + | let dir = tempfile::tempdir().unwrap(); | |
| 413 | + | let id = "01hzxk9m2n8p7q6r5s4t3v2w1y"; | |
| 414 | + | create_bare_with_format( | |
| 415 | + | dir.path(), | |
| 416 | + | id, | |
| 417 | + | "main", | |
| 418 | + | Path::new("/usr/bin/fabrica"), | |
| 419 | + | ObjectFormat::Sha256, | |
| 420 | + | ) | |
| 421 | + | .unwrap(); | |
| 422 | + | ||
| 423 | + | let path = repo_path(dir.path(), id).unwrap(); | |
| 424 | + | // git records the object format under extensions.objectformat. | |
| 425 | + | let out = Command::new("git") | |
| 426 | + | .args(["--git-dir"]) | |
| 427 | + | .arg(&path) | |
| 428 | + | .args(["rev-parse", "--show-object-format"]) | |
| 429 | + | .output() | |
| 430 | + | .unwrap(); | |
| 431 | + | assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256"); | |
| 432 | + | // The server hooks are installed as for a SHA-1 repo. | |
| 433 | + | assert!(path.join("hooks").join("post-receive").exists()); | |
| 434 | + | } | |
| 271 | 435 | } | |
crates/model/src/entity.rs +2 −0
| @@ -388,6 +388,8 @@ pub struct Repo { | |||
| 388 | 388 | pub visibility: Visibility, | |
| 389 | 389 | /// The branch shown by default and used for HEAD. | |
| 390 | 390 | pub default_branch: String, | |
| 391 | + | /// The object hash algorithm (`sha1` or `sha256`), fixed at creation. | |
| 392 | + | pub object_format: String, | |
| 391 | 393 | /// Whether issues are enabled for this repository. | |
| 392 | 394 | pub issues_enabled: bool, | |
| 393 | 395 | /// Whether pull requests are enabled for this repository. | |
crates/store/src/repos.rs +24 −2
| @@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |||
| 12 | 12 | ||
| 13 | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | |
| 14 | 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ | |
| 15 | - | default_branch, issues_enabled, pulls_enabled, next_iid, archived_at, size_bytes, \ | |
| 16 | - | pushed_at, created_at, updated_at"; | |
| 15 | + | default_branch, object_format, issues_enabled, pulls_enabled, next_iid, archived_at, \ | |
| 16 | + | size_bytes, pushed_at, created_at, updated_at"; | |
| 17 | 17 | ||
| 18 | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, | |
| 19 | 19 | /// and the derived size/push bookkeeping. | |
| @@ -66,6 +66,7 @@ impl Store { | |||
| 66 | 66 | description: new.description, | |
| 67 | 67 | visibility: new.visibility, | |
| 68 | 68 | default_branch: new.default_branch, | |
| 69 | + | object_format: "sha1".to_string(), | |
| 69 | 70 | issues_enabled: true, | |
| 70 | 71 | pulls_enabled: true, | |
| 71 | 72 | next_iid: 1, | |
| @@ -101,6 +102,26 @@ impl Store { | |||
| 101 | 102 | Ok(repo) | |
| 102 | 103 | } | |
| 103 | 104 | ||
| 105 | + | /// Set a repository's object format (`sha1` | `sha256`). Called right after | |
| 106 | + | /// [`Store::create_repo`] when a non-default format was chosen, before the | |
| 107 | + | /// on-disk repository is created, so the row matches the on-disk repo. | |
| 108 | + | /// | |
| 109 | + | /// # Errors | |
| 110 | + | /// | |
| 111 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 112 | + | pub async fn set_object_format( | |
| 113 | + | &self, | |
| 114 | + | repo_id: &str, | |
| 115 | + | object_format: &str, | |
| 116 | + | ) -> Result<(), StoreError> { | |
| 117 | + | sqlx::query("UPDATE repos SET object_format = $1 WHERE id = $2") | |
| 118 | + | .bind(object_format) | |
| 119 | + | .bind(repo_id) | |
| 120 | + | .execute(&self.pool) | |
| 121 | + | .await?; | |
| 122 | + | Ok(()) | |
| 123 | + | } | |
| 124 | + | ||
| 104 | 125 | /// Resolve a repository by `(owner_id, path)`, or `None` if absent. This is | |
| 105 | 126 | /// the authoritative name→repo lookup; the on-disk tree is keyed by id. | |
| 106 | 127 | /// | |
| @@ -404,6 +425,7 @@ impl Store { | |||
| 404 | 425 | visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?) | |
| 405 | 426 | .unwrap_or(Visibility::Private), | |
| 406 | 427 | default_branch: row.try_get("default_branch")?, | |
| 428 | + | object_format: row.try_get("object_format")?, | |
| 407 | 429 | issues_enabled: self.get_bool(row, "issues_enabled")?, | |
| 408 | 430 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, | |
| 409 | 431 | next_iid: row.try_get("next_iid")?, | |
crates/web/src/pages.rs +26 −1
| @@ -274,6 +274,9 @@ pub struct NewRepoForm { | |||
| 274 | 274 | /// Optional default branch override. | |
| 275 | 275 | #[serde(default)] | |
| 276 | 276 | default_branch: String, | |
| 277 | + | /// Object format (`sha1` | `sha256`). | |
| 278 | + | #[serde(default)] | |
| 279 | + | object_format: String, | |
| 277 | 280 | /// CSRF token. | |
| 278 | 281 | #[serde(rename = "_csrf")] | |
| 279 | 282 | csrf: String, | |
| @@ -368,8 +371,22 @@ async fn create_repo_from_form( | |||
| 368 | 371 | other => other.to_string(), | |
| 369 | 372 | })?; | |
| 370 | 373 | ||
| 374 | + | let format = git::ObjectFormat::from_token(&form.object_format); | |
| 375 | + | if format != git::ObjectFormat::Sha1 { | |
| 376 | + | // Record the non-default format on the row before creating the on-disk repo. | |
| 377 | + | let _ = state | |
| 378 | + | .store | |
| 379 | + | .set_object_format(&repo.id, format.as_str()) | |
| 380 | + | .await; | |
| 381 | + | } | |
| 371 | 382 | let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); | |
| 372 | - | if let Err(e) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) { | |
| 383 | + | if let Err(e) = git::create_bare_with_format( | |
| 384 | + | &state.config.storage.repo_dir, | |
| 385 | + | &repo.id, | |
| 386 | + | &branch, | |
| 387 | + | &hook, | |
| 388 | + | format, | |
| 389 | + | ) { | |
| 373 | 390 | let _ = state.store.delete_repo(&repo.id).await; | |
| 374 | 391 | return Err(format!("Could not create the repository on disk: {e}")); | |
| 375 | 392 | } | |
| @@ -406,6 +423,14 @@ fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) | |||
| 406 | 423 | label for="default_branch" { "Default branch" } | |
| 407 | 424 | input type="text" id="default_branch" name="default_branch" | |
| 408 | 425 | placeholder=(state.config.instance.default_branch); | |
| 426 | + | label for="object_format" { "Object format" } | |
| 427 | + | select id="object_format" name="object_format" { | |
| 428 | + | option value="sha1" selected { "SHA-1 (legacy, widely compatible)" } | |
| 429 | + | option value="sha256" { "SHA-256 (modern, experimental tooling support)" } | |
| 430 | + | } | |
| 431 | + | p class="muted field-hint" { | |
| 432 | + | "SHA-256 repositories are collision-resistant but not yet supported by all git clients and hosts. This cannot be changed later." | |
| 433 | + | } | |
| 409 | 434 | button class="btn btn-primary" type="submit" { "Create repository" } | |
| 410 | 435 | } | |
| 411 | 436 | } | |
crates/web/src/repo.rs +4 −0
| @@ -791,6 +791,10 @@ async fn repo_settings_view( | |||
| 791 | 791 | input type="checkbox" name="pulls_enabled" value="1" | |
| 792 | 792 | checked[ctx.repo.pulls_enabled]; " Pull requests" | |
| 793 | 793 | } | |
| 794 | + | p class="muted field-hint" { | |
| 795 | + | "Object format: " (ctx.repo.object_format.to_uppercase()) | |
| 796 | + | " · fixed at creation." | |
| 797 | + | } | |
| 794 | 798 | } | |
| 795 | 799 | div class="settings-actions" { | |
| 796 | 800 | button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" } | |
crates/web/src/tests.rs +49 −0
| @@ -885,6 +885,55 @@ async fn pull_request_create_view_and_merge() { | |||
| 885 | 885 | } | |
| 886 | 886 | ||
| 887 | 887 | #[tokio::test] | |
| 888 | + | async fn new_repo_can_use_sha256_object_format() { | |
| 889 | + | if std::process::Command::new("git") | |
| 890 | + | .arg("--version") | |
| 891 | + | .output() | |
| 892 | + | .is_err() | |
| 893 | + | { | |
| 894 | + | return; | |
| 895 | + | } | |
| 896 | + | let (state, app, _dir) = app_with_git().await; | |
| 897 | + | let cookie = login(&app).await; | |
| 898 | + | let token = cookie | |
| 899 | + | .split("fabrica_csrf=") | |
| 900 | + | .nth(1) | |
| 901 | + | .and_then(|s| s.split(';').next()) | |
| 902 | + | .unwrap() | |
| 903 | + | .to_string(); | |
| 904 | + | ||
| 905 | + | let req = Request::builder() | |
| 906 | + | .method("POST") | |
| 907 | + | .uri("/new") | |
| 908 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 909 | + | .header(header::COOKIE, cookie) | |
| 910 | + | .body(Body::from(format!( | |
| 911 | + | "name=modern&visibility=private&default_branch=main&object_format=sha256&_csrf={token}" | |
| 912 | + | ))) | |
| 913 | + | .unwrap(); | |
| 914 | + | let res = app.oneshot(req).await.unwrap(); | |
| 915 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER); | |
| 916 | + | ||
| 917 | + | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | |
| 918 | + | let repo = state | |
| 919 | + | .store | |
| 920 | + | .repo_by_owner_path(&ada.id, "modern") | |
| 921 | + | .await | |
| 922 | + | .unwrap() | |
| 923 | + | .unwrap(); | |
| 924 | + | assert_eq!(repo.object_format, "sha256", "row records the format"); | |
| 925 | + | // The on-disk repo is really SHA-256. | |
| 926 | + | let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap(); | |
| 927 | + | let out = std::process::Command::new("git") | |
| 928 | + | .arg("--git-dir") | |
| 929 | + | .arg(&path) | |
| 930 | + | .args(["rev-parse", "--show-object-format"]) | |
| 931 | + | .output() | |
| 932 | + | .unwrap(); | |
| 933 | + | assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256"); | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | #[tokio::test] | |
| 888 | 937 | async fn pulls_404_when_disabled() { | |
| 889 | 938 | let (state, app) = app().await; | |
| 890 | 939 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | |
migrations/postgres/0007_repo_object_format.sql +3 −0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | -- The object hash algorithm each repository was created with. Existing repos are | |
| 2 | + | -- SHA-1; new repos may opt into SHA-256. | |
| 3 | + | ALTER TABLE repos ADD COLUMN object_format TEXT NOT NULL DEFAULT 'sha1'; | |
migrations/sqlite/0007_repo_object_format.sql +3 −0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | -- The object hash algorithm each repository was created with. Existing repos are | |
| 2 | + | -- SHA-1; new repos may opt into SHA-256. | |
| 3 | + | ALTER TABLE repos ADD COLUMN object_format TEXT NOT NULL DEFAULT 'sha1'; | |