Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/auth/src/access.rs +1 −0
| @@ -147,6 +147,7 @@ mod tests { | |||
| 147 | description: None, | 147 | description: None, |
| 148 | visibility, | 148 | visibility, |
| 149 | default_branch: "main".to_string(), | 149 | default_branch: "main".to_string(), |
| 150 | object_format: "sha1".to_string(), | ||
| 150 | issues_enabled: true, | 151 | issues_enabled: true, |
| 151 | pulls_enabled: true, | 152 | pulls_enabled: true, |
| 152 | next_iid: 1, | 153 | next_iid: 1, |
crates/git/src/lib.rs +164 −0
| @@ -39,6 +39,7 @@ use std::fs; | |||
| 39 | use std::io; | 39 | use std::io; |
| 40 | use std::os::unix::fs::PermissionsExt; | 40 | use std::os::unix::fs::PermissionsExt; |
| 41 | use std::path::{Path, PathBuf}; | 41 | use std::path::{Path, PathBuf}; |
| 42 | use std::process::Command; | ||
| 42 | 43 | ||
| 43 | use git2::{Repository, RepositoryInitOptions}; | 44 | use git2::{Repository, RepositoryInitOptions}; |
| 44 | 45 | ||
| @@ -102,6 +103,10 @@ pub enum GitError { | |||
| 102 | /// rebase replay hit conflicts. | 103 | /// rebase replay hit conflicts. |
| 103 | #[error("merge has conflicts")] | 104 | #[error("merge has conflicts")] |
| 104 | MergeConflict, | 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 | /// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`. | 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 | /// The fallible body of [`create_bare`], separated so the caller can clean up on | 299 | /// The fallible body of [`create_bare`], separated so the caller can clean up on |
| 166 | /// any error. | 300 | /// any error. |
| 167 | fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> { | 301 | fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> { |
| @@ -268,4 +402,34 @@ mod tests { | |||
| 268 | // A second create at the same id fails (the directory already exists). | 402 | // A second create at the same id fails (the directory already exists). |
| 269 | assert!(create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).is_err()); | 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 | pub visibility: Visibility, | 388 | pub visibility: Visibility, |
| 389 | /// The branch shown by default and used for HEAD. | 389 | /// The branch shown by default and used for HEAD. |
| 390 | pub default_branch: String, | 390 | pub default_branch: String, |
| 391 | /// The object hash algorithm (`sha1` or `sha256`), fixed at creation. | ||
| 392 | pub object_format: String, | ||
| 391 | /// Whether issues are enabled for this repository. | 393 | /// Whether issues are enabled for this repository. |
| 392 | pub issues_enabled: bool, | 394 | pub issues_enabled: bool, |
| 393 | /// Whether pull requests are enabled for this repository. | 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 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. |
| 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ | 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, \ | 15 | default_branch, object_format, issues_enabled, pulls_enabled, next_iid, archived_at, \ |
| 16 | pushed_at, created_at, updated_at"; | 16 | size_bytes, pushed_at, created_at, updated_at"; |
| 17 | 17 | ||
| 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, |
| 19 | /// and the derived size/push bookkeeping. | 19 | /// and the derived size/push bookkeeping. |
| @@ -66,6 +66,7 @@ impl Store { | |||
| 66 | description: new.description, | 66 | description: new.description, |
| 67 | visibility: new.visibility, | 67 | visibility: new.visibility, |
| 68 | default_branch: new.default_branch, | 68 | default_branch: new.default_branch, |
| 69 | object_format: "sha1".to_string(), | ||
| 69 | issues_enabled: true, | 70 | issues_enabled: true, |
| 70 | pulls_enabled: true, | 71 | pulls_enabled: true, |
| 71 | next_iid: 1, | 72 | next_iid: 1, |
| @@ -101,6 +102,26 @@ impl Store { | |||
| 101 | Ok(repo) | 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 | /// Resolve a repository by `(owner_id, path)`, or `None` if absent. This is | 125 | /// Resolve a repository by `(owner_id, path)`, or `None` if absent. This is |
| 105 | /// the authoritative name→repo lookup; the on-disk tree is keyed by id. | 126 | /// the authoritative name→repo lookup; the on-disk tree is keyed by id. |
| 106 | /// | 127 | /// |
| @@ -404,6 +425,7 @@ impl Store { | |||
| 404 | visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?) | 425 | visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?) |
| 405 | .unwrap_or(Visibility::Private), | 426 | .unwrap_or(Visibility::Private), |
| 406 | default_branch: row.try_get("default_branch")?, | 427 | default_branch: row.try_get("default_branch")?, |
| 428 | object_format: row.try_get("object_format")?, | ||
| 407 | issues_enabled: self.get_bool(row, "issues_enabled")?, | 429 | issues_enabled: self.get_bool(row, "issues_enabled")?, |
| 408 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, | 430 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, |
| 409 | next_iid: row.try_get("next_iid")?, | 431 | next_iid: row.try_get("next_iid")?, |
crates/web/src/pages.rs +26 −1
| @@ -274,6 +274,9 @@ pub struct NewRepoForm { | |||
| 274 | /// Optional default branch override. | 274 | /// Optional default branch override. |
| 275 | #[serde(default)] | 275 | #[serde(default)] |
| 276 | default_branch: String, | 276 | default_branch: String, |
| 277 | /// Object format (`sha1` | `sha256`). | ||
| 278 | #[serde(default)] | ||
| 279 | object_format: String, | ||
| 277 | /// CSRF token. | 280 | /// CSRF token. |
| 278 | #[serde(rename = "_csrf")] | 281 | #[serde(rename = "_csrf")] |
| 279 | csrf: String, | 282 | csrf: String, |
| @@ -368,8 +371,22 @@ async fn create_repo_from_form( | |||
| 368 | other => other.to_string(), | 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 | let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); | 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 | let _ = state.store.delete_repo(&repo.id).await; | 390 | let _ = state.store.delete_repo(&repo.id).await; |
| 374 | return Err(format!("Could not create the repository on disk: {e}")); | 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 | label for="default_branch" { "Default branch" } | 423 | label for="default_branch" { "Default branch" } |
| 407 | input type="text" id="default_branch" name="default_branch" | 424 | input type="text" id="default_branch" name="default_branch" |
| 408 | placeholder=(state.config.instance.default_branch); | 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 | button class="btn btn-primary" type="submit" { "Create repository" } | 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 | input type="checkbox" name="pulls_enabled" value="1" | 791 | input type="checkbox" name="pulls_enabled" value="1" |
| 792 | checked[ctx.repo.pulls_enabled]; " Pull requests" | 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 | div class="settings-actions" { | 799 | div class="settings-actions" { |
| 796 | button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" } | 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 | #[tokio::test] | 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 | async fn pulls_404_when_disabled() { | 937 | async fn pulls_404_when_disabled() { |
| 889 | let (state, app) = app().await; | 938 | let (state, app) = app().await; |
| 890 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | 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'; | ||