- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
217 lines
14 KiB
Markdown
217 lines
14 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: a10-f006-requireidentifier
|
|
title: 지워도 test가 초록인 검사가 셋이다
|
|
topic: caching-and-redis
|
|
project: clean-architecture-backend-template
|
|
status: 게시 전
|
|
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
|
rootTreeNode: case:a10-f006-requireidentifier
|
|
evidenceCapturedOn: 2026-09-02
|
|
body: case-a10-f006-requireidentifier.body.md
|
|
assets:
|
|
- key: a10-f006-requireidentifier
|
|
file: ../../../final/evidence/rendered/a10-f006-requireidentifier.svg
|
|
- key: a10-f006-requireidentifier-branch
|
|
file: ../../../final/evidence/rendered/a10-f006-requireidentifier-branch.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/a10-f006-requireidentifier.txt
|
|
- ../../../final/evidence/raw/a10-f006-requireidentifier-branch.txt
|
|
source:
|
|
- 원본 분석 절은 final/document.md#a10#L375 이다. 등급은 P3 이다. 문자 클래스가 메일과 전화 형태의 필수 문자를 이미 배제하므로 두 분기가 도달 불가라는 관찰, 대응 test 가 예외 타입만 보므로 그 사실을 가리지 않는다는 지적, 보안 효과는 그대로이고 잃는 것은 진단 품질과 검증 겹수의 착시라는 판단, 그리고 두 분기를 지우거나 검사 순서를 뒤집으라는 제안이 그 절에 있다.
|
|
- 이 기록이 더한 것은 셋이다. 두 입력에 실제로 돌아오는 메시지가 문자 클래스 메시지라는 실행 결과. 웹 토큰 분기는 도달하지만 그것을 겨냥한 test 입력이 접두 검사에도 걸려 지워도 초록이고, 혼자 잡는 것은 실제 토큰이 아니라 합성 값이라는 것. 그리고 네 메시지를 단언하는 곳이 저장소에 하나도 없다는 것이다. 원본이 도달 불가로 센 것은 둘이고, 지워도 test 가 초록인 것은 셋이다.
|
|
---
|
|
|
|
# 지워도 test가 초록인 검사가 셋이다
|
|
|
|
식별자 검증이 다섯 겹으로 보인다. 그중 둘은 앞선 문자 클래스 검사가 이미 걸러내 도달하지 않고, 하나는 도달하지만 그것을 겨냥한 test 입력이 뒤 검사에도 걸린다. 셋 다 지워도 test 는 초록이다.
|
|
|
|
## 관계
|
|
|
|
- **커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다**
|
|
두 사례 모두 조건이 성립할 수 없어 그 분기가 실행되지 않는다.
|
|
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
|
|
예외 타입만 보는 단언은 어느 검사가 던졌는지 구분하지 않는다.
|
|
- **상한을 주입받는 자리는 있고 주입하는 곳은 없다**
|
|
같은 리프의 다른 검증 사례다.
|
|
|
|
## 문제
|
|
|
|
식별자 검증에 검사가 다섯 있다. 문자 클래스 하나와 구체적 형태 넷이다. 메일 주소, 웹 토큰, 국제 전화번호, 인증 재료 접두다.
|
|
|
|
각 검사가 실제로 발화하는지, 그리고 발화한다면 어느 test 가 그것을 붙들고 있는지 확인했다.
|
|
|
|
## 결론
|
|
|
|
문자 클래스는 첫 문자로 영숫자를 요구하고 이후 문자로 영숫자와 점과 물결과 밑줄과 붙임표를 허용한다. 길이는 128 이하다.
|
|
|
|
거기에 골뱅이도 더하기도 없다.
|
|
|
|
그래서 메일 분기는 도달하지 않는다. 골뱅이를 포함한 값은 첫 검사에서 탈락한다. 전화 분기도 도달하지 않는다. 국제 전화번호 패턴이 반드시 더하기로 시작하는데 더하기는 첫 문자로도 이후 문자로도 허용되지 않는다.
|
|
|
|
실행으로 확인했다. 두 형태를 넣으면 돌아오는 메시지가 문자 클래스 메시지다. 무작위 입력 20만 개에서도 이 두 검사에서 갈린 값이 하나도 없다.
|
|
|
|
웹 토큰 분기는 도달한다. 웹 토큰 패턴이 쓰는 문자를 문자 클래스가 모두 허용하므로, 26자에서 128자 사이이고 영숫자로 시작하는 토큰 형태는 첫 검사를 통과해 전용 검사에 닿는다.
|
|
|
|
다만 그것이 잡는 것은 진짜 토큰이 아니다. 진짜 토큰은 늘 같은 세 글자로 시작하고 길이도 128자를 넘긴다. 그런 값은 접두 검사나 문자 클래스가 먼저 잡는다. 혼자 걸리는 값은 토큰 흉내를 낸 합성 문자열뿐이다.
|
|
|
|
그리고 그것을 겨냥한 test 입력 하나가 다음 검사에도 걸린다. 그 값이 eyJ 로 시작해서 접두 검사가 같은 타입의 예외를 낸다. 웹 토큰 분기를 지워도 그 test 는 초록이다.
|
|
|
|
남는 둘은 붙들려 있다. 문자 클래스 검사는 128자 초과 입력과 구분자 주입을 거부하는 test 가 붙들고, 접두 검사는 웹 토큰 형태가 아닌 두 입력이 붙든다. 둘 중 하나를 지우면 대응 test 가 빨개진다.
|
|
|
|
저 네 메시지는 저장소에서 던지는 자리에만 있다. 단언하는 곳이 없다.
|
|
|
|
판정은 P3 이다.
|
|
|
|
거부는 그대로다. 세 형태는 여전히 전부 거부된다.
|
|
|
|
잃는 것은 둘이다. 운영자가 받는 메시지가 구체 형태에서 문자 클래스로 내려앉는다. 그리고 세 분기가 test 에 붙들리지 않은 채 검증이 다섯 겹인 것처럼 보이게 만든다.
|
|
|
|
원본은 두 분기를 지우고 문자 클래스 메시지에 그 의도를 포함시키거나, 검사 순서를 뒤집어 구체적 형태를 먼저 판정하는 수정을 제안했다. 뒤집는 쪽을 고르면 세 분기가 전부 발화한다.
|
|
|
|
## 검증 환경
|
|
|
|
OpenJDK : 21.0.12
|
|
확인 방식 : 문자 클래스와 후속 분기 대조, 실행 탐침, test 단언 대상 확인
|
|
소스 수정 : x
|
|
|
|
## 재현 조건
|
|
|
|
1. 식별자 검증의 다섯 검사를 순서대로 읽는다.
|
|
2. 각 검사가 쓰는 패턴 넷을 읽는다.
|
|
3. 각 형태 검사가 요구하는 필수 문자가 문자 클래스 안에 있는지 본다.
|
|
4. 이 메서드에 값을 넣는 test 를 모아 무엇을 단언하는지 확인한다.
|
|
5. 그 입력들을 그대로 넣고 실제로 돌아오는 메시지를 본다.
|
|
6. 각 입력이 다섯 중 어느 검사에 걸리는지 전부 표시한다.
|
|
7. 웹 토큰 분기에만 걸리는 값과, 실제 크기의 토큰을 각각 넣어 본다.
|
|
8. 무작위 입력으로 각 검사에서 갈린 수를 센다.
|
|
9. 저장소 전체에서 네 메시지가 나오는 곳을 센다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
검사는 아래 순서로 돈다.
|
|
|
|
## 다섯 검사
|
|
|
|
:::evidence key="a10-f006-requireidentifier" alt="식별자 검증 메서드의 다섯 검사 전체와 그 검사들이 쓰는 정규식 넷, 이 메서드에 값을 넣는 test 다섯 개가 무엇을 단언하는지, 그리고 저장소 전체에서 네 예외 메시지가 나오는 곳을 파일 형식 제한 없이 센 터미널 기록." caption="검사 다섯은 문자 클래스·메일·웹 토큰·전화·접두 순 · test 다섯 개의 단언은 모두 예외 타입뿐 · 네 메시지는 던지는 자리 넷에만 있고 단언하는 곳이 없다 — 78줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```java
|
|
if (value == null || !IDENTIFIER.matcher(value).matches()) {
|
|
throw new IllegalArgumentException(
|
|
"identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a"
|
|
+ " key separator");
|
|
}
|
|
String lowerCase = value.toLowerCase(Locale.ROOT);
|
|
if (value.indexOf('@') >= 0) {
|
|
throw new IllegalArgumentException("identifier must not contain a mail address");
|
|
}
|
|
if (JSON_WEB_TOKEN.matcher(value).matches()) {
|
|
throw new IllegalArgumentException("identifier must not contain a JSON web token");
|
|
}
|
|
if (INTERNATIONAL_PHONE.matcher(value).matches()) {
|
|
throw new IllegalArgumentException("identifier must not contain a phone number");
|
|
}
|
|
if (lowerCase.startsWith("bearer") || lowerCase.startsWith("eyj")) {
|
|
throw new IllegalArgumentException("identifier must not contain authentication material");
|
|
}
|
|
```
|
|
|
|
첫 검사가 쓰는 문자 클래스에는 골뱅이도 더하기도 없다.
|
|
|
|
```text
|
|
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$");
|
|
|
|
private static final Pattern JSON_WEB_TOKEN =
|
|
Pattern.compile("^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$");
|
|
|
|
private static final Pattern INTERNATIONAL_PHONE = Pattern.compile("^\\+\\d[\\d.~-]{7,}$");
|
|
```
|
|
|
|
## 넣어 보면 어느 메시지가 오는가
|
|
|
|
:::evidence key="a10-f006-requireidentifier-branch" alt="test 가 쓰는 여섯 입력을 실제 검증 메서드에 넣어 돌아온 메시지와, 각 입력이 다섯 검사 중 어디에 걸리는지 전부 표시한 표. 웹 토큰 분기에만 걸리는 합성 값, 밑줄로 시작하는 값, 실제 크기의 토큰. 그리고 무작위 입력 20만 개로 옮겨 적은 정규식과 실제 메시지를 대조하고 각 검사에서 갈린 수를 함께 센 터미널 기록." caption="메일·전화 입력이 받는 메시지는 문자 클래스 메시지 · 웹 토큰 test 입력은 접두 검사에도 걸리고 실제 크기 토큰은 문자 클래스에서 먼저 걸린다 · 무작위 20만 개에서 메일·전화 검사에서 갈린 입력은 0 — 27줄 · exit 0" zoom="true"
|
|
:::
|
|
|
|
```text
|
|
입력 길이 걸리는 검사 전부 | requireIdentifier 가 낸 메시지
|
|
test: 메일 18 문자클래스 메일 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
|
test: 전화 13 문자클래스 전화 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
|
test: eyj 20 접두 | identifier must not contain authentication material
|
|
test: bearer 19 접두 | identifier must not contain authentication material
|
|
test: JWT 57 JWT 접두 | identifier must not contain a JSON web token
|
|
test: 129자 129 문자클래스 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
|
```
|
|
|
|
메일과 전화 입력은 전용 검사 앞에서 이미 걸린다. 문자 클래스가 골뱅이와 더하기를 빼 놓았으니 그 둘에 도달할 값 자체가 없다.
|
|
|
|
웹 토큰 입력은 전용 검사에 닿는다. 다만 같은 값이 다음 검사에도 걸린다 — `eyJ` 로 시작하기 때문이다.
|
|
|
|
## 웹 토큰 분기가 혼자 잡는 것
|
|
|
|
```text
|
|
합성 JWT 26자 26 JWT | identifier must not contain a JSON web token
|
|
_로 시작 26 문자클래스 JWT | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
|
실제 크기 JWT 238 문자클래스 JWT 접두 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
|
```
|
|
|
|
혼자 걸리려면 26자에서 128자 사이이고 영숫자로 시작하며 `eyJ` 로도 `bearer` 로도 시작하지 않아야 한다. 실제 토큰은 헤더가 `{"` 로 시작해 base64url 로 늘 `eyJ` 가 되고, 길이도 128자를 넘기 일쑤다. 그러니 이 분기가 혼자 잡는 것은 토큰을 닮은 합성 값이다.
|
|
|
|
## 어느 검사를 지우면 test 가 빨개지는가
|
|
|
|
웹 토큰 분기는 일을 하지만 붙들고 있는 test 가 없다. 저 test 입력은 분기를 지워도 접두 검사가 같은 타입의 예외를 낸다.
|
|
|
|
문자 클래스 검사와 접두 검사는 다르다. 129자 입력과 `1:2` 는 문자 클래스 검사가 없으면 아무 검사에도 안 걸리고, `eyJhbGciOiJIUzI1NiJ9` 와 `bearer-abcdefabcdef` 는 접두 검사 말고 걸리는 데가 없다.
|
|
|
|
## 왜 test 가 이것을 못 잡는가
|
|
|
|
```java
|
|
assertThatThrownBy(() -> new RedisKeyName("user", "person@example.com"))
|
|
.isInstanceOf(IllegalArgumentException.class);
|
|
```
|
|
|
|
단언 대상이 예외 타입뿐이다. 어느 검사가 던졌는지 보지 않는다.
|
|
|
|
```text
|
|
sdk/api/key/RedisKeyRules.java:67: throw new IllegalArgumentException("identifier must not contain a mail address");
|
|
sdk/api/key/RedisKeyRules.java:70: throw new IllegalArgumentException("identifier must not contain a JSON web token");
|
|
sdk/api/key/RedisKeyRules.java:73: throw new IllegalArgumentException("identifier must not contain a phone number");
|
|
sdk/api/key/RedisKeyRules.java:76: throw new IllegalArgumentException("identifier must not contain authentication material");
|
|
```
|
|
|
|
저장소 전체를 파일 형식 제한 없이 훑어도 네 메시지가 나오는 곳은 던지는 자리 넷뿐이다.
|
|
|
|
## 옮겨 적은 정규식을 믿어도 되는가
|
|
|
|
표의 「걸리는 검사」 열은 소스에서 옮겨 적은 정규식으로 계산한다. 그 사본이 실제와 같은지 무작위 입력으로 대조했다.
|
|
|
|
```text
|
|
200000 / 200000 일치
|
|
문자클래스 에서 갈린 입력 144166
|
|
메일 에서 갈린 입력 0
|
|
JWT 에서 갈린 입력 23462
|
|
전화 에서 갈린 입력 0
|
|
접두 에서 갈린 입력 30843
|
|
통과 1529
|
|
```
|
|
|
|
문자 클래스와 웹 토큰과 접두는 각각 수만 번씩 갈렸다. 메일과 전화는 0 인데, 그게 이 기록의 결론이다. 도달할 값이 없으니 대조할 방법도 없다.
|
|
|
|
## 잃는 것
|
|
|
|
거부는 그대로다. 세 형태 모두 거부된다.
|
|
|
|
운영자가 보는 메시지가 달라진다. 메일 주소를 담지 말라는 문장 대신 문자 클래스 문장이 온다.
|
|
|
|
그리고 세 분기가 검증을 다섯 겹처럼 보이게 만든다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
세 분기를 실제로 지운 빌드로 전체 test 를 돌리지 않았다. 소스를 고치지 않는 것이 이 작업의 조건이다. 대신 각 입력이 걸리는 검사를 전부 표시해, 지운 뒤에도 같은 타입의 예외를 낼 검사가 남는지 확인했다.
|
|
|
|
분기와 함께 쓰이지 않게 되는 패턴 필드까지 지운 빌드가 경고 없이 컴파일되는지도 확인하지 못했다.
|
|
|
|
<!-- body:end -->
|