- 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>
12 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a07-f002-normalize | 정규형에서 한 글자를 지운 입력이 거부되지 않고 정규형으로 채워진다 | identity-and-identifier | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a07-f002-normalize | 2026-09-02 | case-a07-f002-normalize.body.md |
|
|
|
정규형에서 한 글자를 지운 입력이 거부되지 않고 정규형으로 채워진다
정규화 메서드의 계약은 정규형만 받고 형식 오류는 예외로 거부한다고 적는다. 구현이 위임하는 표준 파서는 대시로 나뉜 다섯 그룹이면 각 그룹의 자릿수를 보지 않는다. 거부됐어야 할 입력이 앞을 0 으로 채운 정규형 식별자로 돌아온다.
관계
- UuidCodec 의 메서드를 부르는 줄은 자기 명세 다섯뿐이다 이 결함이 아직 노출되지 않은 이유다.
- sanitize가 아니라 reject가 기본이다 이 계약이 따르겠다고 적은 규칙이다.
- 그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다 기존 명세가 이것을 놓친 이유다.
문제
계약은 자바독과 README 양쪽에 적혀 있다. 대소문자를 가리지 않는 정규형 문자열을 받아 36자 소문자형을 돌려주고, 형식이 잘못된 값에는 예외를 던진다는 것이다.
구현은 한 줄이다. 표준 라이브러리의 문자열 파서를 부르고 결과를 다시 문자열로 만든다.
결론
빠른 경로 조건은 길이 36 과 대시 위치 8·13·18·23 이고, 거기서 벗어난 값은 다섯 그룹으로 잘라 각각 수로 읽힌다. 자릿수는 검사하지 않고, 규정 자릿수를 넘으면 상위 비트를 버린 채 통과시킨다.
실행으로 확인했다. 정규형 36자에서 마지막 그룹의 한 글자를 지운 입력이 예외 없이 통과하고, 지운 자리 앞을 0 으로 채운 정규형 문자열이 돌아온다.
어느 글자를 지우느냐가 결과를 가른다. 앞자리 0 은 지워도 수가 같아서 결과가 입력과 일치한다. 그 외 자리를 지우면 형식이 올바른 다른 식별자가 돌아온다. 대시를 지운 값은 예외로 떨어진다.
두 번째가 이 결함의 실질이다. 잘린 식별자가 거부되지 않고 다른 대상을 가리키는 식별자가 된다.
길이만으로는 막히지 않는다. 길이가 36 이어도 대시 위치가 다르면 같은 관대한 경로로 떨어진다.
받아들이는 범위가 십육진으로 한정되지도 않는다. 부호가 붙은 값과 아라비아·인도 숫자가 통과한다.
거부 자체는 작동한다. 대시를 뺀 32자나 UUID 형태가 아닌 값은 예외로 떨어진다. 계약과 어긋나는 지점은 거부 기준이다.
관대함이 이 메서드에 갇혀 있지도 않다. 같은 파서를 쓰는 형제 메서드가 재작성된 128비트 값을 그대로 호출자에게 넘긴다.
기존 명세가 이것을 놓친 이유도 코드에 있다. 거부 단언이 하나뿐이고, 그 문자열은 대시 그룹이 다섯이 아니라 관대한 경로에 닿지 않는다.
호출자가 준 텍스트를 저장 형태로 바꾸는 지점이 이 메서드다. 관대함이 남은 자리가 하필 신뢰 경계다. 다만 지금 이 메서드를 부르는 프로덕션 코드가 없어 노출은 0 이고, 그 타입을 단일 경로로 올리는 순간 결함이 된다.
수정은 파서에 넘기기 전에 정규형 정규식으로 거르는 것이다. 길이 검사만으로는 부족하다. 또는 계약 문구를 실제 동작에 맞추는 것이다. 앞의 것이 문서가 말하는 바다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 실행 탐침 소스 수정 : x
재현 조건
- 클래스 산출물이 현재 소스보다 새로운지 확인한다.
- 자바독과 README 의 계약 문구를 읽는다.
- 정규형 36자에서 마지막 그룹의 선행 0, 마지막 글자, 중간 글자, 그리고 대시를 각각 하나씩 지워 넣는다.
- 각 결과가 원본과 같은지 비교한다.
- 길이가 36 이면서 대시 위치가 다른 문자열을 넣는다.
- 그룹이 규정 자릿수를 넘는 문자열과 부호·비라틴 숫자를 넣는다.
- 대시가 없는 32자와 UUID 가 아닌 문자열을 넣는다.
- 같은 파서를 쓰는 형제 메서드에 같은 입력을 넣는다.
- 명세의 거부 단언 수를 센다.
- 같은 저장소에서 이 파서를 쓰면서 먼저 거르는 코드를 찾는다.
본문
계약은 자바독과 README 양쪽에 적혀 있고, 구현은 한 줄이다.
계약과 한 줄
:::evidence key="a07-f002-normalize" alt="정규화 메서드의 자바독 계약과 한 줄짜리 구현, 같은 계약이 적힌 README 두 줄. 같은 파서를 쓰는 형제 메서드. 명세의 거부 케이스와 그 수. 그리고 같은 저장소의 GraphQL UUID 스칼라가 쓰는 정규형 정규식 상수와 파서보다 먼저 그것을 돌리는 관문, 그 관문을 지나는 호출 지점들을 출력한 터미널 기록." caption="계약은 형식 오류에 예외를 던진다고 적음 · 구현은 표준 파서 호출 한 줄 · 명세의 거부 단언은 하나 · UUID 스칼라는 정규식 상수를 두고 파서보다 먼저 돌린다 — 47줄 · exit 0" zoom="true" :::
/**
* Accepts a case-insensitive canonical UUID string and returns the canonical 36-character
* lowercase form; {@code null} input returns {@code null}.
*
* @throws IllegalArgumentException on a malformed UUID
*/
public static String normalize(String input) {
if (input == null) {
return null;
}
return UUID.fromString(input).toString();
}
표준 파서는 길이 36 이면서 대시가 8·13·18·23 에 있는 빠른 경로를 벗어나면, 대시로 나뉜 다섯 그룹을 각각 수로 읽는다. 자릿수는 검사하지 않는다.
마지막 그룹에서 한 글자를 지운 입력
:::evidence key="a07-f002-normalize-probe" alt="클래스 산출물이 소스보다 새로운지 확인한 결과와 JVM 판본. 계약이 말하는 두 갈래의 입력을 넣은 결과. 정규형 36자에서 선행 0·마지막 글자·중간 글자·대시를 각각 하나씩 지운 입력의 결과와 원본과의 동일 여부. 길이가 36 이면서 대시 위치가 다른 문자열, 그룹이 규정 자릿수를 넘는 문자열, 부호와 비라틴 숫자를 넣은 결과. 서로 다른 다섯 입력이 어떤 식별자들로 모이는지. 그리고 같은 파서를 쓰는 형제 메서드의 결과를 출력한 터미널 기록." caption="선행 0 을 지우면 원본과 같은 값, 다른 자리를 지우면 다른 값, 대시를 지우면 예외 · 길이 36 이어도 대시 위치가 다르면 통과 · 초과 자릿수는 상위 비트를 버림 · 부호와 아라비아·인도 숫자도 통과 · 다섯 입력이 두 식별자로 모임 — 36줄 · exit 0" zoom="true" :::
정규형 36자에서 자리를 바꿔 가며 한 글자씩 지워 넣었다.
normalize("0190bd6e-7c3e-7abc-8def-123456789ab") -> "0190bd6e-7c3e-7abc-8def-0123456789ab"
원본과 같은가 : true
normalize("0190bd6e-7c3e-7abc-8def-0123456789a") -> "0190bd6e-7c3e-7abc-8def-00123456789a"
원본과 같은가 : false
normalize("0190bd6e7c3e-7abc-8def-0123456789ab") -> IllegalArgumentException: Invalid UUID string: …
원본과 같은가 : false
세 결과가 다르다. 선행 0 을 지우면 값이 같아 원본이 돌아온다. 마지막 글자를 지우면 앞을 0 으로 채운 다른 식별자가 돌아온다. 대시를 지우면 예외가 난다.
가운데가 이 결함의 실질이다. 잘린 문자열이 거부되지 않고, 형식이 올바르면서 다른 대상을 가리키는 식별자가 된다.
길이 검사로는 막히지 않는다
normalize("0000001-00001-0001-0001-000000000001") -> "00000001-0001-0001-0001-000000000001"
이 입력은 36자다. 대시 위치가 다를 뿐인데 빠른 경로를 벗어나 관대한 경로로 떨어진다. length() != 36 만 검사하는 수정은 이것을 통과시킨다.
받는 것이 십육진에 한정되지도 않는다.
normalize("100000001-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
normalize("+1-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
normalize("١-1-1-1-1") -> "00000001-0001-0001-0001-000000000001"
첫 줄은 아홉 자리 그룹이고, 상위 비트가 버려진 채 통과한다. 나머지 둘은 부호와 아라비아·인도 숫자다.
거부가 고장 난 것은 아니다. 대시 없는 32자와 UUID 가 아닌 문자열은 예외를 던진다. 계약과 다른 것은 거부의 기준이다.
서로 다른 입력이 모이는 자리
00000001-0001-0001-0001-000000000001 <- "1-1-1-1-1", "01-01-01-01-01", "+1-1-1-1-1", "00000001-0001-0001-0001-000000000001"
00000001-0001-0001-0001-000000000002 <- "1-1-1-1-2"
넷이 하나로 모이고, 다섯째는 따로 선다. 결과만 보고는 앞의 셋이 넷째와 다른 문자열이었다는 것을 알 수 없다.
같은 파서를 쓰는 형제 메서드도 같은 값을 낸다. 관대함은 정규화 메서드에 갇혀 있지 않고, 재작성된 128비트 값이 그대로 호출자에게 간다.
toUuid("1-1-1-1-1") = 00000001-0001-0001-0001-000000000001
명세의 거부 케이스
def "normalize 는 형식이 잘못된 UUID 를 거부한다"() {
when:
UuidCodec.normalize("not-a-uuid")
then:
thrown(IllegalArgumentException)
}
명세 전체의 거부 단언은 이 하나다. 그 문자열은 대시 그룹이 다섯이 아니라 관대한 경로에 닿지 않는다.
UUID 스칼라는 파서보다 정규식을 먼저 돌린다
같은 저장소의 다른 리프가 같은 파서를 쓰면서 계약을 문구가 아니라 코드로 지킨다.
public static UUID parse(String value) {
if (value == null || !CANONICAL.matcher(value).matches()) {
throw new CoercingParseValueException("invalid UUID");
}
CANONICAL 은 정규형 정규식 상수이고, 값을 받는 세 진입점이 모두 이 한 메서드를 지난다. 수정에 필요한 정규식이 이미 저장소 안에 있다.
지금은 아무도 부르지 않는다
이 타입의 메서드를 부르는 줄은 저장소 전체에서 자기 명세 다섯뿐이다. 프로덕션 호출자가 없으므로 현재 노출은 0 이다.
이 메서드는 호출자가 준 텍스트를 저장 형태로 바꾸는 지점이다. 관대함이 남은 자리가 신뢰 경계라는 것이 이 어긋남의 무게이고, 그 무게는 이 타입을 문자열에서 UUID 로 가는 단일 경로로 올리는 순간 실현된다.
확인하지 못한 것
표준 라이브러리 판본에 따라 관대한 경로가 달라지는지 확인하지 않았다. 확인한 것은 이 프로젝트가 쓰는 판본이다.