fabrica

hanna/fabrica

feat(git): SHA-1 / SHA-256 object-format selection for new repos

5e112e6 · hanna committed on 2026-07-25

Add an ObjectFormat type and create_bare_with_format: SHA-1 keeps the in-process
git2 path; SHA-256 is created via the git binary (git init --object-format=
sha256), since libgit2's SHA-256 support is experimental — the repo is still a
valid git repo over the pack transport. Store the format per repo (migration
0007), expose a selector on the new-repo form (SHA-1 default, SHA-256 labelled
experimental), and show it read-only in repo settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +276 −3UnifiedSplit
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;
39use std::io;39use std::io;
40use std::os::unix::fs::PermissionsExt;40use std::os::unix::fs::PermissionsExt;
41use std::path::{Path, PathBuf};41use std::path::{Path, PathBuf};
42use std::process::Command;
4243
43use git2::{Repository, RepositoryInitOptions};44use git2::{Repository, RepositoryInitOptions};
4445
@@ -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}
106111
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}
164169
170/// The hash algorithm a repository's objects are named with.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub 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
180impl 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.
213pub 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.
228fn 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`].
257fn 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 on299/// The fallible body of [`create_bare`], separated so the caller can clean up on
166/// any error.300/// any error.
167fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> {301fn 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};
1212
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`.
14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \14const 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";
1717
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 }
103104
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 is125 /// 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 })?;
370373
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}
886886
887#[tokio::test]887#[tokio::test]
888async 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]
888async fn pulls_404_when_disabled() {937async 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.
3ALTER 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.
3ALTER TABLE repos ADD COLUMN object_format TEXT NOT NULL DEFAULT 'sha1';