fabrica

hanna/fabrica

feat(store): cap group nesting at MAX_GROUP_DEPTH levels

27c2452 · hanna committed on 2026-07-26

ensure_group_path — the single choke point for group creation across web,
API, SSH push-to-create, and the CLI — now rejects paths deeper than
model::MAX_GROUP_DEPTH (5) with StoreError::GroupTooDeep, keeping repo
paths, breadcrumbs, and URLs to a sane length. The API maps it to 400;
the web/SSH repo-create paths already surface the message inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
20 files changed · +91 −1UnifiedSplit
crates/api/src/lib.rs +1 −0
@@ -93,6 +93,7 @@ impl From<store::StoreError> for ApiError {
93 Self::Conflict(format!("{entity} already exists with that {field}"))93 Self::Conflict(format!("{entity} already exists with that {field}"))
94 }94 }
95 store::StoreError::Name(e) => Self::BadRequest(e.to_string()),95 store::StoreError::Name(e) => Self::BadRequest(e.to_string()),
96 e @ store::StoreError::GroupTooDeep { .. } => Self::BadRequest(e.to_string()),
96 other => Self::internal(other),97 other => Self::internal(other),
97 }98 }
98 }99 }
crates/model/src/lib.rs +1 −1
@@ -15,4 +15,4 @@ pub use entity::{
15 ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo,15 ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo,
16 Timestamp, User, UserEmail, Visibility,16 Timestamp, User, UserEmail, Visibility,
17};17};
18pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};18pub use name::{MAX_GROUP_DEPTH, MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
crates/model/src/name.rs +6 −0
@@ -21,6 +21,12 @@
21/// 38 more, matching the `{0,38}` quantifier).21/// 38 more, matching the `{0,38}` quantifier).
22pub const MAX_NAME_LEN: usize = 39;22pub const MAX_NAME_LEN: usize = 39;
2323
24/// The deepest a group may nest — the maximum number of group segments in a
25/// group path (`a/b/c/d/e` is five, the limit). Keeps repo paths, breadcrumbs,
26/// and URLs to a sane length; a repo filed under the deepest group has one more
27/// segment than this.
28pub const MAX_GROUP_DEPTH: usize = 5;
29
24/// Names that would collide with a top-level route or a reserved path segment.30/// Names that would collide with a top-level route or a reserved path segment.
25///31///
26/// Comparison is case-insensitive, so `Admin` is rejected just as `admin` is.32/// Comparison is case-insensitive, so `Admin` is rejected just as `admin` is.
crates/store/src/groups.rs +5 −0
@@ -30,6 +30,11 @@ impl Store {
30 if segments.is_empty() {30 if segments.is_empty() {
31 return Err(StoreError::Name(model::NameError::Empty));31 return Err(StoreError::Name(model::NameError::Empty));
32 }32 }
33 if segments.len() > model::MAX_GROUP_DEPTH {
34 return Err(StoreError::GroupTooDeep {
35 max: model::MAX_GROUP_DEPTH,
36 });
37 }
33 for segment in &segments {38 for segment in &segments {
34 model::validate_name(segment)?;39 model::validate_name(segment)?;
35 }40 }
crates/store/src/lib.rs +6 −0
@@ -124,6 +124,12 @@ pub enum StoreError {
124 /// A name failed validation before it could be written.124 /// A name failed validation before it could be written.
125 #[error("invalid name: {0}")]125 #[error("invalid name: {0}")]
126 Name(#[from] model::NameError),126 Name(#[from] model::NameError),
127 /// A group path nested deeper than [`model::MAX_GROUP_DEPTH`] levels.
128 #[error("groups may nest at most {max} levels deep")]
129 GroupTooDeep {
130 /// The maximum permitted nesting depth.
131 max: usize,
132 },
127 /// A uniqueness constraint was violated (e.g. a duplicate username).133 /// A uniqueness constraint was violated (e.g. a duplicate username).
128 #[error("{entity} already exists with that {field}")]134 #[error("{entity} already exists with that {field}")]
129 Conflict {135 Conflict {
crates/store/src/tests.rs +28 −0
@@ -797,6 +797,34 @@ async fn ensure_group_path_creates_intermediates_and_reuses() {
797}797}
798798
799#[tokio::test]799#[tokio::test]
800async fn ensure_group_path_rejects_excessive_nesting() {
801 for fx in sqlite_fixtures().await {
802 let owner = fx
803 .store
804 .create_user(sample_user("deep", "deep@example.com"))
805 .await
806 .unwrap();
807 // Exactly MAX_GROUP_DEPTH levels is allowed.
808 let at_limit = (1..=model::MAX_GROUP_DEPTH)
809 .map(|n| format!("g{n}"))
810 .collect::<Vec<_>>()
811 .join("/");
812 assert!(
813 fx.store
814 .ensure_group_path(&owner.id, &at_limit)
815 .await
816 .is_ok()
817 );
818 // One level deeper is rejected.
819 let too_deep = format!("{at_limit}/extra");
820 assert!(matches!(
821 fx.store.ensure_group_path(&owner.id, &too_deep).await,
822 Err(StoreError::GroupTooDeep { max }) if max == model::MAX_GROUP_DEPTH
823 ));
824 }
825}
826
827#[tokio::test]
800async fn keys_get_stable_per_user_ordinals() {828async fn keys_get_stable_per_user_ordinals() {
801 for fx in sqlite_fixtures().await {829 for fx in sqlite_fixtures().await {
802 let user = fx830 let user = fx
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/HEAD +1 −0
@@ -0,0 +1 @@
1ref: refs/heads/main
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/config +9 −0
@@ -0,0 +1,9 @@
1[core]
2 bare = true
3 repositoryformatversion = 0
4 filemode = true
5 logallrefupdates = true
6[receive]
7 denyNonFastForwards = false
8[gc]
9 auto = 0
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/description +1 −0
@@ -0,0 +1 @@
1Unnamed repository; edit this file 'description' to name the repository.
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/hooks/README.sample +5 −0
@@ -0,0 +1,5 @@
1#!/bin/sh
2#
3# Place appropriately named executable hook scripts into this directory
4# to intercept various actions that git takes. See `git help hooks` for
5# more information.
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/hooks/post-receive +2 −0
@@ -0,0 +1,2 @@
1#!/bin/sh
2exec "/nix/store/f93gacynk1wfdwg1s90pybfy828cdic0-fabrica-0.1.0/bin/.fabrica-wrapped" hook post-receive
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/hooks/pre-receive +2 −0
@@ -0,0 +1,2 @@
1#!/bin/sh
2exit 0
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/info/exclude +2 −0
@@ -0,0 +1,2 @@
1# File patterns to ignore; see `git help ignore` for more information.
2# Lines that start with '#' are comments.
data/repos/01/01kydxcsngxmfpk2bzfdt0sfmx.git/HEAD +1 −0
@@ -0,0 +1 @@
1ref: refs/heads/main
data/repos/01/01kydxcsngxmfpk2bzfdt0sfmx.git/config +9 −0
@@ -0,0 +1,9 @@
1[core]
2 bare = true
3 repositoryformatversion = 0
4 filemode = true
5 logallrefupdates = true
6[receive]
7 denyNonFastForwards = false
8[gc]
9 auto = 0
data/repos/01/01kydxcsngxmfpk2bzfdt0sfmx.git/description +1 −0
@@ -0,0 +1 @@
1Unnamed repository; edit this file 'description' to name the repository.
data/repos/01/01kydxcsngxmfpk2bzfdt0sfmx.git/hooks/README.sample +5 −0
@@ -0,0 +1,5 @@
1#!/bin/sh
2#
3# Place appropriately named executable hook scripts into this directory
4# to intercept various actions that git takes. See `git help hooks` for
5# more information.
data/repos/01/01kydxcsngxmfpk2bzfdt0sfmx.git/hooks/post-receive +2 −0
@@ -0,0 +1,2 @@
1#!/bin/sh
2exec "/nix/store/f93gacynk1wfdwg1s90pybfy828cdic0-fabrica-0.1.0/bin/.fabrica-wrapped" hook post-receive
data/repos/01/01kydxcsngxmfpk2bzfdt0sfmx.git/hooks/pre-receive +2 −0
@@ -0,0 +1,2 @@
1#!/bin/sh
2exit 0
data/repos/01/01kydxcsngxmfpk2bzfdt0sfmx.git/info/exclude +2 −0
@@ -0,0 +1,2 @@
1# File patterns to ignore; see `git help ignore` for more information.
2# Lines that start with '#' are comments.