Skip to content

Commit 4f503b7

Browse files
authored
fix(contract): validate role names for stable machine output (#42)
Harden the role-name contract so machine-readable surfaces like `status --short` stay predictable. Also: - centralize role-name validation in `src/domain/role.ts` - require role names to match `^[a-z0-9_-]+$` - add `InvalidRoleNameError` with clear user-facing messages - enforce validation in role creation and import - enforce validation on direct role-referencing commands - make invalid persisted role data fail clearly during normalization - add focused CLI and use-case coverage for valid/invalid names - keep `status --short` exact and safe for valid names Verified with: - npm run build - npm test - npm run test:e2e - npm run test:release
1 parent 4e95ba9 commit 4f503b7

7 files changed

Lines changed: 525 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Breaking Changes
6+
7+
* role names are now restricted to lowercase letters, numbers, `_`, and `-` so machine-readable output like `gitrole status --short` stays stable
8+
* existing saved roles with names outside that policy, such as `client acme`, `Work`, or `prod/main`, now fail clearly and should be renamed or recreated with a contract-safe name
9+
310
## [0.7.0](https://github.com/synsoftworks/gitrole/compare/v0.6.0...v0.7.0) (2026-04-12)
411

512

src/application/use-cases/pin.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Creates a strict repo-local .gitrole policy for the current repository.
33
*/
4+
import { validateRoleName } from '../../domain/role.js';
45
import type { PinRepoPolicyDependencies, PinRepoPolicyResult } from '../contracts.js';
56
import { saveRepoPolicy } from '../repo-policy.js';
67
import { ProfileNotFoundError } from './role.js';
@@ -29,10 +30,11 @@ export async function pinRepoPolicy(
2930
dependencies: PinRepoPolicyDependencies,
3031
name: string
3132
): Promise<PinRepoPolicyResult> {
32-
const role = await dependencies.roleStore.get(name);
33+
const roleName = validateRoleName(name);
34+
const role = await dependencies.roleStore.get(roleName);
3335

3436
if (!role) {
35-
throw new ProfileNotFoundError(name);
37+
throw new ProfileNotFoundError(roleName);
3638
}
3739

3840
if (!(await dependencies.repository.isInsideWorkTree())) {

src/application/use-cases/remote.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Rewrites repository remotes to match the selected role's GitHub SSH host.
33
*/
4+
import { validateRoleName } from '../../domain/role.js';
45
import { type RemoteUseDependencies, type RemoteUseResult } from '../contracts.js';
56
import { ProfileNotFoundError } from './role.js';
67

@@ -45,14 +46,15 @@ export async function useRemoteForRole(
4546
dependencies: RemoteUseDependencies,
4647
name: string
4748
): Promise<RemoteUseResult> {
48-
const role = await dependencies.roleStore.get(name);
49+
const roleName = validateRoleName(name);
50+
const role = await dependencies.roleStore.get(roleName);
4951

5052
if (!role) {
51-
throw new ProfileNotFoundError(name);
53+
throw new ProfileNotFoundError(roleName);
5254
}
5355

5456
if (!role.githubHost) {
55-
throw new RoleMissingGithubHostError(name);
57+
throw new RoleMissingGithubHostError(roleName);
5658
}
5759

5860
const previousUrl = await dependencies.repository.getOriginUrl();

src/application/use-cases/role.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
/*
22
* Implements role creation, activation, lookup, and removal workflows.
33
*/
4-
import { matchesIdentity, normalizeRole, type Role } from '../../domain/role.js';
4+
import {
5+
matchesIdentity,
6+
normalizeRole,
7+
validateRoleName,
8+
type Role
9+
} from '../../domain/role.js';
510
import {
611
type AppDependencies,
712
type CurrentRoleDependencies,
@@ -81,11 +86,12 @@ export async function useRole(
8186
name: string,
8287
options: UseRoleOptions = {}
8388
): Promise<UseRoleResult> {
84-
const role = await dependencies.roleStore.get(name);
89+
const roleName = validateRoleName(name);
90+
const role = await dependencies.roleStore.get(roleName);
8591
const scope = options.scope ?? 'global';
8692

8793
if (!role) {
88-
throw new ProfileNotFoundError(name);
94+
throw new ProfileNotFoundError(roleName);
8995
}
9096

9197
if (scope === 'local') {
@@ -178,14 +184,15 @@ export async function importCurrentRole(
178184
dependencies: CurrentRoleDependencies,
179185
name: string
180186
): Promise<ImportCurrentRoleResult> {
187+
const roleName = validateRoleName(name);
181188
const currentIdentity = await getEffectiveCurrentIdentity(dependencies);
182189

183190
if (!currentIdentity.fullName || !currentIdentity.email) {
184191
throw new IncompleteCurrentIdentityError();
185192
}
186193

187194
const role = await addRole(dependencies, {
188-
name,
195+
name: roleName,
189196
fullName: currentIdentity.fullName,
190197
email: currentIdentity.email
191198
});
@@ -246,13 +253,14 @@ export async function removeRole(
246253
dependencies: AppDependencies,
247254
name: string
248255
): Promise<Role> {
249-
const role = await dependencies.roleStore.get(name);
256+
const roleName = validateRoleName(name);
257+
const role = await dependencies.roleStore.get(roleName);
250258

251259
if (!role) {
252-
throw new ProfileNotFoundError(name);
260+
throw new ProfileNotFoundError(roleName);
253261
}
254262

255-
await dependencies.roleStore.remove(name);
263+
await dependencies.roleStore.remove(roleName);
256264

257265
return role;
258266
}

src/domain/role.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,31 @@ export interface GitIdentity {
1515
email?: string;
1616
}
1717

18+
const roleNamePattern = /^[a-z0-9_-]+$/;
19+
20+
export class InvalidRoleNameError extends Error {
21+
constructor(input: string) {
22+
super(`invalid role name "${input}"; use lowercase letters, numbers, "-" or "_"`);
23+
this.name = 'InvalidRoleNameError';
24+
}
25+
}
26+
27+
export function validateRoleName(input: string): string {
28+
if (!input || input.trim() !== input || !roleNamePattern.test(input)) {
29+
throw new InvalidRoleNameError(input);
30+
}
31+
32+
return input;
33+
}
34+
1835
/**
1936
* Normalizes persisted role input so comparisons and storage stay stable.
2037
*
2138
* Trims all string fields and removes empty optional values.
2239
*/
2340
export function normalizeRole(input: Role): Role {
2441
return {
25-
name: input.name.trim(),
42+
name: validateRoleName(input.name),
2643
fullName: input.fullName.trim(),
2744
email: input.email.trim(),
2845
sshKeyPath: input.sshKeyPath?.trim() || undefined,

0 commit comments

Comments
 (0)