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 {
9393 Self::Conflict(format!("{entity} already exists with that {field}"))
9494 }
9595 store::StoreError::Name(e) => Self::BadRequest(e.to_string()),
96+ e @ store::StoreError::GroupTooDeep { .. } => Self::BadRequest(e.to_string()),
9697 other => Self::internal(other),
9798 }
9899 }
crates/model/src/lib.rs +1 −1
@@ -15,4 +15,4 @@ pub use entity::{
1515 ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo,
1616 Timestamp, User, UserEmail, Visibility,
1717 };
18-pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
18+pub use name::{MAX_GROUP_DEPTH, MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
crates/model/src/name.rs +6 −0
@@ -21,6 +21,12 @@
2121 /// 38 more, matching the `{0,38}` quantifier).
2222 pub 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.
28+pub const MAX_GROUP_DEPTH: usize = 5;
29+
2430 /// Names that would collide with a top-level route or a reserved path segment.
2531 ///
2632 /// 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 {
3030 if segments.is_empty() {
3131 return Err(StoreError::Name(model::NameError::Empty));
3232 }
33+ if segments.len() > model::MAX_GROUP_DEPTH {
34+ return Err(StoreError::GroupTooDeep {
35+ max: model::MAX_GROUP_DEPTH,
36+ });
37+ }
3338 for segment in &segments {
3439 model::validate_name(segment)?;
3540 }
crates/store/src/lib.rs +6 −0
@@ -124,6 +124,12 @@ pub enum StoreError {
124124 /// A name failed validation before it could be written.
125125 #[error("invalid name: {0}")]
126126 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+ },
127133 /// A uniqueness constraint was violated (e.g. a duplicate username).
128134 #[error("{entity} already exists with that {field}")]
129135 Conflict {
crates/store/src/tests.rs +28 −0
@@ -797,6 +797,34 @@ async fn ensure_group_path_creates_intermediates_and_reuses() {
797797 }
798798
799799 #[tokio::test]
800+async 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]
800828 async fn keys_get_stable_per_user_ordinals() {
801829 for fx in sqlite_fixtures().await {
802830 let user = fx
data/repos/01/01kydxc9fqzy2yje8r0w39amae.git/HEAD +1 −0
@@ -0,0 +1 @@
1+ref: 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 @@
1+Unnamed 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
2+exec "/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
2+exit 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 @@
1+ref: 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 @@
1+Unnamed 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
2+exec "/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
2+exit 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.