The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
769 lines
74 KiB
Markdown
769 lines
74 KiB
Markdown
# 08 · adapter-outbound-fileserver
|
||
|
||
|
||
## SSOT identity — 2026-08-31 재검증
|
||
|
||
- registered leaf id: `adapter-outbound-fileserver`
|
||
- canonical state `analysisFile`: `analysis/08-adapter-outbound-fileserver.md` (이 문서) — 이 leaf의 단일 SSOT
|
||
- source path: `src/adapter/outbound/fileserver` · Gradle `:adapter:outbound:fileserver`
|
||
- registry `allowed_dependencies`: `["application-core", "shared-contract"]`
|
||
- registry `runtime_memberships`: `["app-bootstrap"]`
|
||
- coverage ledger: `FULL_READ` **119** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
|
||
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
|
||
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행) — `EVD-334`의 로케일 finding이 이 리프의 것이다
|
||
|
||
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
|
||
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
|
||
|
||
---
|
||
> 상태: COMPLETE
|
||
> revision: `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
|
||
> 경로: `src/adapter/outbound/fileserver` · Gradle: `:adapter:outbound:fileserver`
|
||
|
||
## 0. Denominator와 coverage ledger
|
||
|
||
tracked file **119개** — main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 약 24.7k LOC.
|
||
`build.gradle`에 별도 source set이나 test lane 선언이 없다(`main`/`test`뿐).
|
||
|
||
레지스트리:
|
||
|
||
```json
|
||
{ "id": "adapter-outbound-fileserver",
|
||
"gradle_path": ":adapter:outbound:fileserver",
|
||
"allowed_dependencies": ["application-core", "shared-contract"],
|
||
"runtime_memberships": ["app-bootstrap"] }
|
||
```
|
||
|
||
leaf 밖 소비자는 `app-bootstrap` 하나다 — `CaSkeletonApplication` + `autoconfigure/fileserver/**` 6개 config 클래스, 그리고 test 3개.
|
||
|
||
패키지 배치(main): 루트 `fileserver` 31 · `platform/local` 33 · `platform/verification` 10 · `platform/security` 2 · `platform/audit` 2.
|
||
|
||
### 하위 범위 원장
|
||
|
||
| # | 범위 | main | test | 합 | 상태 |
|
||
|---|---|---|---|---|---|
|
||
| 1 | governance / build / config / activation (+ governance 4) | 7 | 2 | 13 | **COMPLETE** |
|
||
| 2 | control plane + control record codec + recovery verifier | 3 | 3 | 6 | **COMPLETE** |
|
||
| 3 | publication — provider · adapter · journal · attestor · binding | 19 | 7 | 26 | **COMPLETE** |
|
||
| 4 | `platform/local` IO primitive · gateway · store/publisher | 22 | 8 | 30 | **COMPLETE** |
|
||
| 5 | `platform/local` failure·probe·health·orphan + verification + security + audit | 24 | 5 | 29 | **COMPLETE** |
|
||
| 6 | payload operations · CSV export · testkit 계약 · crash matrix | 3 | 12 | 15 | **COMPLETE** |
|
||
| | **TOTAL** | **78** | **37** | **119** (governance 4 포함) | **6 / 6** |
|
||
|
||
manifest: `evidence/raw/141-fileserver-module-inventory.txt`.
|
||
|
||
---
|
||
|
||
## 1. Sub-scope 01 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **13 / 13 FULL_READ**
|
||
> 범위: governance 4 + config/activation main 7 + 전용 test 2
|
||
> 역할: R1(CSV export)과 R2(local-persistent publication) **두 개의 opt-in 선택자**를 서로 혼동될 수 없게 분리하고, 잘못된 조합을 파일시스템에 손대기 전에 거부한다
|
||
|
||
manifest와 probe: `evidence/raw/142-fileserver-config-activation-probes.txt`.
|
||
|
||
## 2. 선택자 세 개가 각자 다른 것을 켠다
|
||
|
||
이 leaf는 이름이 비슷한 세 능력을 명시적으로 갈라 둔다(CLAUDE.md).
|
||
|
||
| namespace | 무엇을 켜는가 | 소유 |
|
||
|---|---|---|
|
||
| `app.fileserver.*` | R2 publication (`local-persistent`) | 이 leaf |
|
||
| `app.file-export.*` | R1 CSV export (+ `legacy-enabled`로 덮어쓰기 가능 legacy port) | 이 leaf |
|
||
| `app.fileserver-platform.*` | HTTP Fileserver **플랫폼**(업로드/다운로드/수명주기 라우트) | `app-bootstrap` |
|
||
|
||
셋 다 기본 off이고, R1과 R2 동시 활성화는 파일시스템 초기화 **전에** 실패한다. `FileserverActivationValidator.rejectAmbiguous(environment)`가 세 bean factory 메서드의 **첫 줄**에서 호출되고(`FileExportConfig:33`·`:49`, `FileserverR2Config:31`), `Binder`로 두 selector를 직접 읽으므로 bean 정의 순서에 의존하지 않는다. test가 그 순서를 고정한다 — `enablingLegacyR1AndR2TogetherFailsBeforeEitherFilesystemIsMutated`는 실패 후 R2 루트의 `.ca-fileserver`·`data`와 R1/legacy 루트가 **모두 존재하지 않음**을 단언한다.
|
||
|
||
R2 쪽 조립은 fail-closed가 촘촘하다. `FileserverR2Config.routingFilePublicationPort`는 destination을 컴파일하고, 서로 다른 provider ID가 같은 루트를 소유하는 조합을 거부하고(`rejectSharedRootAcrossProviderIds`), provider ID별로 **하나의** attestor/control plane/payload 런타임을 만들어 같은 provider를 지목한 모든 destination이 공유하게 한다. test 둘이 그 공유/분리를 각각 확인한다(`destinationsBoundToOneProviderReuseOneProviderRuntime`, `destinationsBoundToDifferentProvidersUseDifferentProviderRuntimes`).
|
||
|
||
`FileserverR2Validation`은 값 검증을 한곳에 모은다 — ID는 `[a-z][a-z0-9-]{0,62}`이고 **이미 정규화돼 있어야** 하며, 경로는 **이미 절대·정규화**돼 있어야 하고, sentinel 이름은 `.`/`..`/구분자/제어문자를 거부한 뒤 UTF-8 인코딩 길이 255바이트와 `getNameCount()==1`까지 확인한다. `maximum-root-mode`는 네 자리 8진수만 받고 **group/world write를 별도로 거부**한다(`(group & 2) != 0 || (others & 2) != 0`).
|
||
|
||
## 3. Confirmed — 비활성 상태에서 부작용이 없다는 것을 test가 실제로 확인한다
|
||
|
||
`disabledR2CreatesNoPortOrFilesystemSideEffect`는 bean 부재만이 아니라 **설정된 루트가 생성되지 않았음**(`assertThat(absentRoot).doesNotExist()`)까지 단언한다. `unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored`도 실패 후 `.ca-fileserver`·`data` 부재를 확인한다. "비활성이면 아무 일도 없다"를 bean 목록이 아니라 파일시스템으로 검증하는 형태다.
|
||
|
||
`rejectsLegacyRootThatAliasesThePublicationRootThroughASymbolicLink`는 심볼릭 링크로 우회한 루트 겹침까지 본다 — `canonicalDirectory`가 `toRealPath()`로 정규화한 뒤 `startsWith`로 양방향 포함을 검사하기 때문에 잡힌다.
|
||
|
||
## 4. P2 — README가 "노출된 setting도 bean도 없다"고 적은 능력들에 production bean이 있다
|
||
|
||
README:103–105의 guarantee boundary 문단이 이렇게 끝난다.
|
||
|
||
> Cross-node producer fencing, background reconciliation/reaping, retention, quota/backpressure, readiness/health, metrics, tracing, and audit are also not implemented. **No setting or bean for those capabilities is exposed.**
|
||
|
||
`app-bootstrap`이 그중 넷에 대해 이 leaf의 타입으로 bean을 만든다(`142-...` §8.4f).
|
||
|
||
| README가 "없다"고 한 것 | 실제 bean | 만드는 곳 |
|
||
|---|---|---|
|
||
| audit | `StructuredAdminAuditAdapter`, `StructuredFileserverAuditAdapter` | `FileserverSecurityConfiguration:71`·`:77` |
|
||
| readiness/health | `LocalStorageHealthAdapter` | `FileserverStorageConfiguration:179` |
|
||
| background reconciliation/reaping | `LocalOrphanScanAdapter`, `LocalReconciliationContentProbe` | `FileserverStorageConfiguration:189`·`:203` |
|
||
| quota | `LocalStorageUsageProbe` | `FileserverStorageConfiguration:196` |
|
||
|
||
**공정하게 볼 지점.** 코드 배치 자체는 앞뒤가 맞는다. 이것들은 R2 publication이 아니라 **HTTP Fileserver 플랫폼**(`app.fileserver-platform.*`, CLAUDE.md가 "owned by `app-bootstrap`"이라 적는 별개 능력)의 부품이고, `build.gradle`의 description도 이 leaf가 "the local filesystem content platform behind the HTTP Fileserver"를 함께 담는다고 밝힌다. main 78개 중 **67개가 `platform/**`**라는 사실이 그 비중을 보여 준다.
|
||
|
||
잘못된 것은 문단의 범위다. "No setting or bean for those capabilities is exposed"에는 한정어가 없고, 이 문단은 독자가 **이 모듈이 무엇을 제공하고 무엇을 제공하지 않는지** 확인하러 오는 자리다. 그 자리에서 "audit은 구현돼 있지 않다"를 읽은 사람은 감사 기록이 없다고 결론짓는데, 같은 저장소가 두 개의 audit adapter를 bean으로 만든다. **판정: P2.** 수정은 문단을 R2 publication 범위로 한정하고, 같은 leaf가 담는 플랫폼 부품이 별도 namespace로 조립된다는 사실을 그 자리에 적는 것이다.
|
||
|
||
(이 finding의 나머지 절반 — 그 bean들이 실제로 무엇을 보장하는가 — 은 `platform/**`을 읽는 sub-scope 05에서 다룬다.)
|
||
|
||
## 5. P3 — R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다
|
||
|
||
같은 leaf 안의 두 selector가 설정을 다르게 다룬다.
|
||
|
||
| | R2 `app.fileserver.*` | R1 `app.file-export.*` |
|
||
|---|---|---|
|
||
| 바인딩 타입 | `record` + **`ignoreUnknownFields = false`** | 가변 JavaBean, 기본값(**미지의 키 무시**) |
|
||
| 루트 경로 | `requireAbsoluteNormalizedPath` — 이미 절대·정규화여야 함 | `Path.of(v).toAbsolutePath().normalize()` — 상대 경로 허용, CWD 기준 절대화 |
|
||
| 기본 루트 | 없음(필수) | `./.data/fileserver`, `./.data/fileserver-legacy` |
|
||
| 디렉터리 생성 | 하지 않음(attestation이 별도로 요구) | `Files.createDirectories(root)`로 **생성** |
|
||
| 미지 키 test | `unknownConfigurationFieldIsRejectedInsteadOfSilentlyIgnored` | **없음** |
|
||
|
||
R2에서는 `strict-path-securty` 같은 오타가 컨텍스트를 실패시킨다. R1에서는 `app.file-export.maximum-rowz=10` 같은 오타가 조용히 무시되고, 설정했다고 믿는 상한이 적용되지 않은 채 기본값 1,000,000이 쓰인다. 두 selector가 같은 leaf의 같은 성격 설정인데 한쪽만 fail-closed다. **P3** — R1은 문서상 "compatibility only"이므로 우선순위를 낮춘다.
|
||
|
||
## 6. P3 — 문서가 지목한 기본값 위치와 test 목록이 실제와 다르다
|
||
|
||
- README는 R2 selector가 "`app-bootstrap/application.yml`에서 `false`로 기본값을 갖는다"고 적는다. 그 파일에 `app.fileserver.enabled`도 `app.file-export.enabled`도 **없다**(`142-...` §8.4e; `app.fileserver`로 걸리는 두 줄은 주석이다). 실효 기본값은 "속성 부재 → `@ConditionalOnProperty` 미매치 → bean 없음"이고 동작은 옳지만, 문서가 가리킨 자리에는 그 키가 없다.
|
||
- README의 Tests 목록 첫 항목 `FilePublicationContractTest`는 이 leaf가 아니라 `application-core`에 있다.
|
||
|
||
## 7. Confirmed — 적재 경로는 auto-configuration이 아니라 명시적 component scan이다
|
||
|
||
이 leaf에는 `META-INF/spring/…AutoConfiguration.imports`가 **없다**(`142-...` §8.1). `FileExportConfig`/`FileserverR2Config`를 leaf 밖에서 이름으로 참조하는 production 코드도 없고, 유일한 외부 참조는 app-bootstrap의 test(`OptionalAdapterBeanGatingTest`)다.
|
||
|
||
실제 적재는 `CaSkeletonApplication`의 명시적 `@ComponentScan`이 `dev.caskeleton.adapter.outbound.fileserver`를 목록에 올려서 이루어진다(`:75`). 즉 CLAUDE.md의 "never activates unexpectedly when merely present on the classpath"는 **classpath 존재만으로 bean이 생기지 않는다**는 뜻으로는 정확하지만, 기전은 import filter가 아니라 "@Configuration은 스캔되고 bean 생성만 `@ConditionalOnProperty`로 막힌다"이다. fail-closed는 성립한다 — 기록해 두는 이유는 mongo leaf의 4중 opt-in(§sub-scope 01, 06번 문서)과 기전이 다르기 때문이다.
|
||
|
||
## 8. Negative-space probes — sub-scope 01
|
||
|
||
- **8.1 reachability**: auto-configuration 등록 metadata 0, 적재는 명시적 component scan(§7). 세 bean 모두 `@ConditionalOnProperty` 게이트.
|
||
- **8.2 조건부 형제**: R1 vs R2의 설정 엄격도·경로 규칙·디렉터리 생성·test 커버리지 비대칭(§5).
|
||
- **8.3 중복 mechanism**: `rejectAmbiguous` 호출 3곳은 중복이 아니라 **각 진입점의 첫 줄**이라는 배치다 — `Binder`로 환경을 직접 읽으므로 bean 순서에 무관하고, app-bootstrap의 `FileserverStartupValidator`는 R1/R2 selector가 아니라 플랫폼 저장소 probe 결과를 검증하는 별개 장치다(중복 아님).
|
||
- **8.4 문서/개수 drift**: §4(가장 무거움) · §6(기본값 위치, test 목록).
|
||
|
||
## 9. Sub-scope 01 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P2** | README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reconciliation/reaping·quota 넷에 대해 사실과 다르다 — 모두 `app-bootstrap`이 이 leaf의 타입으로 bean을 만든다 | 이 문단을 근거로 능력 유무를 판단하는 독자 |
|
||
| **P3** | `FileExportSettings`에 `ignoreUnknownFields=false`가 없어 `app.file-export.*` 오타가 조용히 무시된다(R2는 거부하고 test도 있다) | R1을 켠 배포의 설정 오타 |
|
||
| **P3** | R1 루트는 상대 경로를 허용해 CWD 기준으로 절대화하고 디렉터리를 생성하는데, R2는 이미 절대·정규화된 경로만 받는다 — 같은 leaf의 두 selector가 다른 규칙 | R1 배포 |
|
||
| **P3** | README가 지목한 selector 기본값 위치(`app-bootstrap/application.yml`)에 해당 키가 없고, Tests 목록의 `FilePublicationContractTest`는 `application-core` 소속이다 | 문서 |
|
||
|
||
## 10. Sub-scope 01 완료 조건
|
||
|
||
- denominator 13 / 13 FULL_READ (`142-...` OWNED FILES)
|
||
- §8.1(적재 경로)·§8.2(R1/R2 형제)·§8.3(중복 아님 확인)·§8.4(문서 drift) 네 종 probe 수행
|
||
- §4는 app-bootstrap의 bean 생성 지점을 직접 확인해 판정했고, 그 bean들이 무엇을 보장하는지는 sub-scope 05로 이월
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
## 11. Sub-scope 02 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **6 / 6 FULL_READ**
|
||
> 범위: `LocalPersistentControlPlane` 1,470 + `FileserverControlRecordCodec` 855 + `LocalPersistentRecoveryVerifier` 210 (main 3, 2,535 LOC) + 전용 test 3 (3,240 LOC)
|
||
> 역할: R2의 **강제된(forced) 제어 평면** — 세 종류의 canonical 제어 레코드를 저장·검증하고, 상태 전이를 인접 행렬로 강제하며, 협력 프로세스를 JVM+OS 락으로 직렬화한다
|
||
|
||
manifest와 probe: `evidence/raw/143-fileserver-control-plane-probes.txt`.
|
||
|
||
test/main 비율이 **1.28**이다. 이 sub-scope에서 찾은 결함은 없고, 아래는 왜 없는지에 대한 기록이다.
|
||
|
||
## 12. Confirmed — codec이 "canonical"을 왕복으로 강제한다
|
||
|
||
`FileserverControlRecordCodec`은 세 레코드와 receipt snapshot에 대해 **decode 직후 재encode해 바이트를 비교**한다(`requireCanonical(bytes, encodeOperation(record))`). 그래서 "파싱은 되지만 우리가 쓰지 않았을 형태"가 전부 거부된다 — 공백, 필드 재배열, `A` 같은 이스케이프, `-0`/선행 0 같은 숫자 표기, 후행 콘텐츠. 파서 자체도 좁다.
|
||
|
||
- 필드 집합을 **정확히 일치**시킨다(`values.keySet().equals(allowedFields)`) — 누락도 미지 필드도 거부.
|
||
- 중복 키를 거부한다(`putIfAbsent`).
|
||
- UTF-8 디코딩이 `REPORT` 모드라 malformed 바이트가 대체문자로 조용히 바뀌지 않는다.
|
||
- `\b \f \n \r \t` 이스케이프를 **문법 수준에서 거부**한다("control characters are forbidden") — 제어문자가 이스케이프로 밀입되는 경로를 닫는다.
|
||
- 짝 없는 서로게이트를 거부한다(`requireWellFormedUnicode`).
|
||
- `Instant.parse` 후 `result.toString().equals(value)`로 **canonical UTC 표기**만 받는다.
|
||
- receipt snapshot은 `rsv1.` 접두사 + unpadded base64url이고, 디코딩 후 **재인코딩 문자열 비교**로 alias(후행 비트가 0이 아닌 변형)를 거부한다.
|
||
|
||
test가 그 하나하나를 이름으로 고정한다 — `canonicalDecoderRejectsWhitespaceReorderingEscapesNumbersUtf8AndTrailingContent`, `receiptSnapshotRejectsBase64urlAliasWithNonZeroTrailingBits`, `canonicalCodecRoundTripsSupplementaryUnicodeInOpaqueText`, `formulaMitigationCountCannotExceedCellsAndUsesOverflowSafeBounds`.
|
||
|
||
마지막 것은 코드에서도 확인된다. `requireFormulaCountWithinCells`가 `rowCount * columnCount` 곱을 하기 전에 `rowCount <= Long.MAX_VALUE / columnCount`를 먼저 본다 — 오버플로가 상한 검사를 무력화하는 경로를 닫는다.
|
||
|
||
## 13. Confirmed — 상태 전이가 인접 행렬이고 terminal이 진짜 terminal이다
|
||
|
||
`validateOperationTransition`이 여섯 가지를 순서대로 강제한다: requestFingerprint 불변 → 불변 identity 10개 필드 불변 → `stateRevision` 감소 금지 → 동일 revision 다른 내용 금지 → **정확히 +1** 증가 → 인접 전이 행렬. 행렬은 README가 적은 사슬과 일치하고, 모든 비terminal 상태에서 `QUARANTINED`로만 이탈할 수 있으며 `PUBLISHED`·`QUARANTINED`는 후속 전이가 없다(`case PUBLISHED, QUARANTINED -> false`).
|
||
|
||
봉인 이후 사실은 얼어붙는다 — `requireSealedFactsUnchanged`가 byteSize·rowCount·columnCount·sha256·formulaMitigatedCount·sealedAt을 고정하고, `MANIFEST_PUBLISHED` 이후에는 `manifestDigest`, `REFERENCE_PUBLISHED` 이후에는 `referenceDigest`도 고정된다.
|
||
|
||
`current.equals(candidate)`는 전이가 아니라 **복구(repair)**로 취급된다 — 부모 디렉터리를 다시 force하고 정확 read-back만 수행한다. 크래시 후 같은 레코드를 다시 쓰는 재시도가 conflict가 되지 않게 하는 처리이고, `parentForcedCallbackFailureIsRepairedByIdenticalOperationRetry`가 이를 고정한다.
|
||
|
||
## 14. Confirmed — 두 개의 락 형태가 각자의 쓰기 원시연산에 맞춰져 있다
|
||
|
||
한 클래스 안에 락이 두 종류다. 얼핏 비대칭으로 보이지만 각자의 커밋 방식이 다르다.
|
||
|
||
| | operation 레코드 | manifest / reference 레코드 |
|
||
|---|---|---|
|
||
| 커밋 원시연산 | `Files.move(ATOMIC_MOVE, REPLACE_EXISTING)` — **배타적이지 않음** | `Files.createLink` — 이미 있으면 `FileAlreadyExistsException`, **OS 수준 배타** |
|
||
| JVM 락 | `OPERATION_LOCK_STRIPES`, 키에 **root 범위 포함**(`operationLockRootKey + "\0" + token`) | `IMMUTABLE_LOCK_STRIPES`, 키는 `"manifest:"+fileId` — root 범위 **없음** |
|
||
| OS 락 | `FileChannel.lock()` (`.lock` 파일, 0600, 소유자·FileStore 검증) | 없음 |
|
||
|
||
즉 배타성이 필요한 쪽(replace)에는 OS 락을 두고, 원시연산 자체가 배타적인 쪽(create-link)에는 JVM 스트라이프만 둔 것이다. 후자의 root 미포함은 **과잉 직렬화** 방향이라(다른 root의 같은 fileId가 같은 스트라이프를 공유) 배타 누락으로는 이어지지 않고, `fileId`는 `SecureRandom` 16바이트라 실질 충돌도 없다. 결함이 아니라 설계로 기록한다.
|
||
|
||
collision 경로도 닫혀 있다 — `createLink`가 충돌하면 임시 파일을 정확히 지우고, 기존 레코드를 읽어 identity와 내용 동등성을 확인한 뒤 같으면 repair, 다르면 `CONFLICT`다. `concurrentCrossInstanceImmutableCollisionIsNeverClassifiedAsStorage`가 그 분류를 고정한다.
|
||
|
||
## 15. Confirmed — poisoning은 root 범위이고, 읽기를 막지 않는 것이 의도다
|
||
|
||
OS 언락을 **증명하지 못한** 경우에만 `POISONED_OPERATION_LOCK_ROOTS`에 root 키가 들어간다. release와 close 중 **하나라도** 성공하면 poison하지 않는다(`releaseProvedUnlock || closeProvedUnlock`).
|
||
|
||
`requireOperationLockRootHealthy()`는 6곳에서 호출되는데 전부 쓰기 경로(`storeOperation`·`acquireOperationLock`×3·`storeManifest`·`storeReference`)이고, `findOperation`/`findStoredOperation`/`findManifest`/`findReference` 어디에도 없다. 처음에는 누락으로 보였으나 test 이름이 그것이 의도임을 못박는다 — **`poisonedRootBlocksEveryWriteIncludingHeldLockFastPathButAllowsReads`**. 이미 획득한 락의 fast path(`heldTokens.contains(...)`)조차 poison에 걸린다는 것까지 이름에 들어 있다.
|
||
|
||
poison을 해제하는 경로는 없다(집합은 static이고 제거 호출이 없다). 프로세스 수명 동안 그 root는 쓰기 불가로 남는다 — "OS 락이 풀렸는지 증명할 수 없다"에 대한 fail-closed 응답이고, `operationLockClosePoisonsOnlyTheAttestedRootWhenUnlockCannotBeProven`이 범위가 해당 root에 한정됨을 확인한다. 두 개의 형제 test(`releaseFailureWithSuccessfulChannelCloseReportsStorageWithoutPoisoning`, `successfulReleaseWithChannelCloseFailureReportsStorageWithoutPoisoning`)가 "증명 하나면 충분" 규칙을 양쪽에서 고정한다.
|
||
|
||
## 16. Confirmed — 파일시스템 접근이 전부 `SecureDirectoryStream` 상대 연산이다
|
||
|
||
`SystemSecureRecordOperations`의 다섯 연산이 모두 `openSecure(topDirectory)` → `newDirectoryStream(shard, NOFOLLOW_LINKS)`를 거친다. `SecureDirectoryStream`이 아니면 스트림을 닫고 `IOException`을 던진다 — TOCTOU 우회 경로를 열어 두지 않는다.
|
||
|
||
세부가 촘촘하다.
|
||
|
||
- 읽기는 `maximumBytes + 1` 버퍼로 읽어 **한 바이트 초과분**을 감지하고, 읽기 전후 `fileKey`와 `size`를 비교해 "읽는 중 신원이 바뀐" 경우를 integrity 실패로 만든다.
|
||
- 임시 파일 생성은 `CREATE_NEW` + `NOFOLLOW_LINKS` + 0600이고, 쓴 뒤 `force(true)`, 그 다음 크기와 fileKey를 생성 시점과 대조한다.
|
||
- 커밋 전후로 `requireCreatedTemporaryIdentity`가 **정확히 그 fileKey**만 지운다 — 다른 프로세스가 같은 이름으로 바꿔 둔 파일을 지우지 않는다. `cleanupPreservesAReplacementWhoseNoFollowFileKeyDiffersFromCreatedTemp`가 그 경계를 고정한다.
|
||
- shard 디렉터리는 매번 소유자·0700 권한·FileStore 동일성을 재검증하고, 좌표는 `[0-9a-f]{2}`와 세 허용 디렉터리로 제한된다.
|
||
- 모든 쓰기/읽기 단계 사이에 `verifyAttestedIdentity()`가 끼어 있다 — root가 도중에 바뀌면 즉시 멈춘다.
|
||
|
||
`forceDirectory`는 디렉터리를 `READ`로 열어 `force(true)`한다. README가 `FILE_AND_DIRECTORY_SYNC`를 "attested local file/directory force boundary only"로 한정하는 것과 일치한다.
|
||
|
||
## 17. Confirmed — 세 타입 모두 leaf 밖으로 새지 않는다
|
||
|
||
`LocalPersistentControlPlane`·`FileserverControlRecordCodec`·`LocalPersistentRecoveryVerifier`는 전부 package-private이고, 저장소에서 이 leaf 밖 참조는 **0**이다(`143-...` §8.1). production 생성 지점은 `FileserverR2Config:51` 하나다. CLAUDE.md의 "Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`" 금지가 타입 가시성으로 뒷받침된다.
|
||
|
||
R1 하위호환도 좁게 열려 있다 — `decodeStoredOperation`은 R2 codec을 먼저 시도하고, 실패하면 R1 journal codec으로 넘어가되 **terminal `PUBLISHED`만** 허용한다. CLAUDE.md의 "schema v1 is strict read-only compatibility"와 일치하고, `typedOperationLookupDispatchesCanonicalR2AndTerminalR1FromTheSameHashedPath`와 `typedOperationLookupRejectsMalformedNonCanonicalNonTerminalAndWrongIdentityR1`이 양쪽을 고정한다.
|
||
|
||
## 18. Negative-space probes — sub-scope 02
|
||
|
||
- **8.1 reachability**: 세 타입 모두 package-private, leaf 밖 참조 0, production 진입점 1개(§17).
|
||
- **8.2 조건부 형제**: 한 클래스 안의 두 락 형태(§14) — 커밋 원시연산 차이로 설명됨. `requireOperationLockRootHealthy`의 쓰기/읽기 비대칭(§15) — test 이름이 의도임을 명시.
|
||
- **8.3 중복 mechanism**: poison 집합에 해제 경로 없음(§15, 의도된 fail-closed). R1/R2 두 codec 경로는 dispatch 순서와 terminal 제약으로 분리(§17).
|
||
- **8.4 문서/동작 대조**: README의 상태 사슬(`WRITING → SEALED → DATA_PUBLISHED → MANIFEST_PUBLISHED → REFERENCE_PUBLISHED → PUBLISHED`)과 `isAllowedAdjacentTransition`의 행렬이 일치. `FILE_AND_DIRECTORY_SYNC`의 한정 서술과 `forceDirectory` 구현이 일치.
|
||
|
||
## 19. Sub-scope 02 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| — | **없음.** 후보로 본 세 가지(immutable 락의 root 미포함, poison 해제 경로 부재, 읽기 경로의 health 게이트 부재)는 각각 커밋 원시연산·fail-closed 설계·명시적 test로 의도임이 확인됐다 | — |
|
||
|
||
## 20. Sub-scope 02 완료 조건
|
||
|
||
- denominator 6 / 6 FULL_READ (`143-...` OWNED FILES) — main 2,535 LOC 전수 판독
|
||
- §8.1~§8.4 네 종 probe 수행, 후보 finding 3건을 각각 코드·test로 추적해 결함 아님으로 판정
|
||
- 실행 probe 불필요 — 세 후보 모두 소스와 test 이름으로 정적 결정 가능
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
## 21. Sub-scope 03 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **26 / 26 FULL_READ**
|
||
> 범위: publication main 19 (provider·adapter·journal·attestor·binding·record, 약 4,400 LOC) + 전용 test 7
|
||
> 역할: 요청 → 스테이지 → 데이터 → manifest → reference → terminal 사슬을 **재개 가능한 상태 기계**로 만들고, 루트를 startup에 증명하며, R1 아티팩트를 읽기 전용으로만 복원한다
|
||
|
||
manifest와 probe: `evidence/raw/144-fileserver-publication-probes.txt`.
|
||
|
||
## 22. Confirmed — 19개 production 타입 중 leaf를 벗어나는 것이 하나도 없다
|
||
|
||
전수 검색 결과 `LocalPersistentPublicationProvider`·`LocalFilePublicationAdapter`·`RoutingFilePublicationAdapter`·`LocalPersistentRootAttestor`·`FileserverBindingCompiler`·`DurablePublicationRecord`·`PrivateFileManifest`·`PublishedReferenceRecord`·`LocalPublicationJournal` 어느 것도 이 leaf 밖에서 참조되지 않는다(`144-...` §8.1, exit=1). 전부 package-private이고, 애플리케이션이 보는 것은 `FilePublicationPort`와 그 값 타입뿐이다.
|
||
|
||
CLAUDE.md의 금지 조항 — "Leaking filesystem, stream, framework, or provider types across `FilePublicationPort`" — 이 문서가 아니라 **타입 가시성**으로 뒷받침된다. `FilePublicationProvider`(adapter 내부 provider 인터페이스)도 package-private이라 provider 개념 자체가 포트를 건너지 않는다.
|
||
|
||
## 23. Confirmed — 복구가 "어디서 끊겼든 그 자리에서" 재개하는 루프다
|
||
|
||
`recoverR2`는 저장된 상태에 따라 분기하는 `while(true)` 루프다. 각 단계가 증거를 다시 검증하고, 성공하면 다음 상태로 전이하며, 루프가 `PUBLISHED`에 도달하면 receipt를 복원한다.
|
||
|
||
| 저장 상태 | 재개 동작 |
|
||
|---|---|
|
||
| `WRITING` | **격리**(`UNSEALED_WRITING`) — 봉인 전에 끊긴 것은 재개하지 않는다 |
|
||
| `SEALED` | stage/data를 조사해 둘 다 없으면 integrity 실패, data가 있으면 디렉터리만 force, 없으면 stage를 hard-link로 publish |
|
||
| `DATA_PUBLISHED` | manifest를 찾거나 생성해 저장 |
|
||
| `MANIFEST_PUBLISHED` | reference를 찾거나 생성해 저장 |
|
||
| `REFERENCE_PUBLISHED` | 모든 증거를 재대조하고 receipt snapshot을 넣어 terminal 기록 |
|
||
| `PUBLISHED` | 전 필드 재검증 후 **저장된 receipt를 그대로** 반환 |
|
||
| `QUARANTINED` | indeterminate |
|
||
|
||
핵심은 **producer를 다시 부르지 않는다**는 점이다. `publishNew`만 `streamRequest`를 호출하고, 그 이후의 모든 재개 경로는 이미 봉인된 바이트에서 진행한다. README의 "resumes from verified sealed bytes without replaying the producer"가 코드 구조로 성립한다.
|
||
|
||
`resumeData`의 stage/data 이중 조사가 특히 촘촘하다. 둘 다 존재하면 `fileKey`가 같은지 확인해 — 즉 **같은 exclusive hard-link인지** — 확인하고, 다르면 `RecoveryIntegrityException`이다. hard-link 발행이 성공한 뒤 stage 삭제 전에 죽은 경우와, 전혀 다른 파일이 그 자리에 있는 경우를 구분한다.
|
||
|
||
실패 분류도 갈라져 있다. `RecoveryIntegrityException`과 payload의 `INTEGRITY`/`CAPACITY`는 **격리 후** indeterminate가 되고, 그 밖의 payload 실패는 격리 없이 indeterminate다. `quarantineAndIndeterminate`는 이미 `PUBLISHED`/`QUARANTINED`인 기록은 건드리지 않는다.
|
||
|
||
## 24. Confirmed — 루트 증명이 "설정을 믿지 않는" 형태다
|
||
|
||
`LocalPersistentRootAttestor.attestChecked`가 순서대로 확인한다: 절대·정규화 경로 → 심볼릭 루트/조상 거부 → `toRealPath()`가 설정 경로와 **정확히 일치** → 소유자 → 권한 상한 → FileStore 이름·타입 → mount sentinel의 SHA-256 → 내부 디렉터리 8개 생성/검증 → `SecureDirectoryStream` 가용성 → **실제 capability probe**.
|
||
|
||
마지막이 특징적이다. `runCapabilityProbe`는 실제로 파일을 만들고(`CREATE_NEW`+`NOFOLLOW_LINKS`+0600), 쓰고, `force`하고, **hard-link를 만들고**, 디렉터리를 force한 다음, 원본과 링크의 `fileKey`가 같은지 확인한다. 즉 "이 파일시스템이 배타적 hard-link 발행과 file/directory force를 실제로 할 수 있는가"를 startup에 시험한다 — 첫 publication에서 발견하지 않는다.
|
||
|
||
내부 디렉터리 생성에는 롤백이 붙어 있다. `rollbackCreatedDirectory`는 삭제 전에 부모 identity와 디렉터리 자신의 `fileKey`를 대조하고, 하나라도 바뀌었으면 **삭제를 거부**한다("refusing rollback because internal directory identity changed"). 실패 정리가 남의 디렉터리를 지우지 않는다.
|
||
|
||
`verifyIdentity`는 attest가 끝난 뒤에도 control plane의 거의 모든 단계에서 재호출된다(§16). 증명은 시점이 아니라 불변식이다.
|
||
|
||
## 25. Confirmed — canonical digest가 길이 프레이밍이고, route token 충돌을 명시적으로 검사한다
|
||
|
||
`FilePublicationCanonicalDigests.digestOrderedValues`는 값 개수를 먼저 넣고, 값마다 **길이(4바이트) + 엄격 UTF-8 바이트**를 넣는다. 구분자를 쓰지 않으므로 값 안에 어떤 문자가 있어도 경계가 흐려지지 않는다. `FilePublishRequestFingerprint`도 같은 방식이다.
|
||
|
||
`routeToken`은 정책 다이제스트의 앞 31자에 `r`을 붙인 것이라 **잘린 값**이다. 그래서 `FileserverBindingCompiler.deriveUniqueRouteTokens`가 컴파일 시점에 토큰 충돌을 검사하고, 충돌하면 두 destination 이름을 모두 담아 거부한다. 잘림이 만들 수 있는 유일한 문제를 그 자리에서 닫는다. 컴파일 후에도 `compiled.forEach`로 각 destination의 토큰이 레지스트리와 같은지 다시 확인한다.
|
||
|
||
`CompiledFileDestination`의 compact 생성자는 넘겨받은 `effectivePolicyDigest`를 **다시 계산해 대조**하고, `routeToken`이 그 다이제스트에서 유도됐는지, `formatPolicyDigest`가 정본과 같은지도 확인한다. 값이 아니라 관계를 검증한다.
|
||
|
||
## 26. Confirmed — R1과 R2가 같은 일을 다른 엄격도로 하고, 그 사실이 선언돼 있다
|
||
|
||
두 계층이 나란히 있어 비교가 가능하다(`144-...` §8.2).
|
||
|
||
| | R2 `LocalPersistentControlPlane` | R1 `LocalPublicationJournal` |
|
||
|---|---|---|
|
||
| 파일시스템 접근 | `SecureDirectoryStream` 상대 연산 (**17회**) | `Files.exists`/`isRegularFile`/`readAllBytes` (**0회**) |
|
||
| 읽기 디코딩 | 엄격 UTF-8 `REPORT` + canonical 바이트 재대조 | `new String(bytes, UTF_8)` — malformed는 U+FFFD로 대체 |
|
||
| 제어문자 이스케이프 | `\b \f \n \r \t`를 **문법에서 거부** | 다섯 개를 모두 **수용해 디코드** |
|
||
| POSIX 권한 | 정확히 0700이 아니면 실패 | `UnsupportedOperationException`을 삼키고 진행 |
|
||
| 임시 파일명 | `SecureRandom` 16바이트 hex | `UUID.randomUUID()` |
|
||
| 락 | `ReentrantLock` 스트라이프 + OS `FileLock` + poison 래치 | `Semaphore` 스트라이프 + OS `FileLock` |
|
||
|
||
이것은 결함이 아니라 선언된 상태다 — CLAUDE.md는 R1을 "compatibility only"로, README는 "must not be used as R2 durability or cluster-safety evidence"로 못박는다.
|
||
|
||
**중요한 것은 두 계층이 만나는 한 지점이다.** R2 control plane이 같은 해시 경로에서 R1 저널을 읽을 때(`decodeStoredOperation`) 쓰는 것은 관대한 `decode`가 아니라 **엄격한 `decodeCanonical`**이고, 그 위에 `state == PUBLISHED`까지 요구한다(`LocalPersistentControlPlane:634-638`). 즉 R1의 느슨함이 R2 경로로 흘러들지 않는다. 이 한 줄이 위 표 전체를 안전하게 만든다.
|
||
|
||
R1 복원이 등급을 올리지 않는 것도 코드로 확인된다 — `restoreR1`은 receipt에 `DurabilityGuarantee.PROCESS_LOCAL_SYNC`를 그대로 넣고, 참조도 R2의 `fsr1.…` 형식이 아니라 R1의 `filepub:<destination>:<token>` 형식을 쓴다. README의 "never writes schema v1, creates an R2 manifest/reference for that artifact, or promotes its durability guarantee"와 일치한다.
|
||
|
||
## 27. Negative-space probes — sub-scope 03
|
||
|
||
- **8.1 reachability**: 19개 production 타입 전부 package-private, leaf 밖 참조 0(§22). production 진입점은 `FileserverR2Config`(R2)와 `FileExportConfig`(R1) 둘.
|
||
- **8.2 조건부 형제**: R1/R2의 6개 축 엄격도 대조(§26), 그리고 두 계층의 접점이 엄격 경로를 쓰는지 확인.
|
||
- **8.3 중복 mechanism**: 참조 형식 둘(`filepub:` / `fsr1.`)과 락 구현 둘 — 각각 R1/R2 경계에 대응하고 서로 침범하지 않음. `LocalPersistentPublicationProvider`와 `LocalFilePublicationAdapter`가 같은 `filepub:` 형식을 쓰는 것은 R1 receipt 호환을 위한 의도된 공유.
|
||
- **8.4 문서/동작 대조**: README의 여섯 단계 사슬 ↔ `recoverR2` 분기, "producer를 재생하지 않는다" ↔ `publishNew`만 `streamRequest` 호출, R1 등급 비승격 ↔ `PROCESS_LOCAL_SYNC` 고정, route token 잘림 ↔ 컴파일 시 충돌 검사.
|
||
|
||
## 28. Sub-scope 03 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| — | **없음.** R1/R2 엄격도 격차는 문서가 선언한 상태이고, 두 계층이 만나는 유일한 지점(`decodeStoredOperation`)은 엄격 경로를 쓴다 | — |
|
||
|
||
## 29. Sub-scope 03 완료 조건
|
||
|
||
- denominator 26 / 26 FULL_READ (`144-...` OWNED FILES) — main 약 4,400 LOC 전수 판독
|
||
- §8.1~§8.4 네 종 probe 수행, R1/R2 접점을 코드로 추적해 느슨함이 전파되지 않음을 확인
|
||
- 실행 probe 불필요 — 판정 지점이 모두 정적으로 결정 가능
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
## 30. Sub-scope 04 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **30 / 30 FULL_READ**
|
||
> 범위: `platform/local` IO 원시연산 9 + gateway 7 + store·publisher 6 (main 22) + 전용 test 8
|
||
> 역할: HTTP Fileserver 플랫폼의 **로컬 콘텐츠 저장소** — 스테이징·추가·발행·읽기·삭제를 경로가 아니라 **디렉터리 서술자 상대 연산**으로 수행한다
|
||
|
||
manifest와 probe: `evidence/raw/145-fileserver-local-io-probes.txt`.
|
||
|
||
## 31. Confirmed — TOCTOU를 "검사를 더 하는" 방식으로 풀지 않는다
|
||
|
||
`SecureDirectoryWalk`의 클래스 javadoc이 이 sub-scope의 설계 명제를 그대로 적는다.
|
||
|
||
> The pathname approach cannot be made safe by adding checks. Proving that no component of `${root}/content/ab/cd` is a symbolic link and then calling `FileChannel.open` on that string re-resolves every component from scratch… **More checks only narrow the window; they never close it.**
|
||
|
||
그래서 각 단계가 **이전 디렉터리의 서술자를 기준으로** 다음 디렉터리를 `NOFOLLOW_LINKS`로 연다. 열어 둔 서술자는 나중에 그 디렉터리가 교체돼도 영향받지 않는다 — "an attacker who swaps a component afterwards has swapped something nothing is looking at any more".
|
||
|
||
세부도 논리적이다.
|
||
|
||
- **fallback을 두지 않는다.** `SecureDirectoryStream`이 없으면 startup capability probe가 실패로 처리한다 — "a silent fall back to pathnames would restore exactly the window this class exists to close".
|
||
- **거부와 장애를 구분한다.** `NOFOLLOW_LINKS` 거부는 플랫폼이 일반 `FileSystemException`으로 보고하므로, 실패 시 같은 부모 서술자로 그 컴포넌트를 다시 읽어 심볼릭 링크인지 확인하고 `SymbolicComponentException`(영구 거부)과 스토리지 장애(재시도 가능한 503)를 나눈다.
|
||
- **`FileChannel`이 아니면 거부한다.** `requireFileChannel`은 positional write·`truncate`·`force`·`transferTo`가 전부 `FileChannel` 연산이고 "cannot be emulated"라고 적으며 거부한다.
|
||
- **경로 해석은 한 곳뿐.** `DefaultPhysicalPathResolver`가 유일하게 식별자를 경로로 바꾸고, 세 겹으로 막는다 — 서버 생성 형태 정규식(`[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}`), 정규화, 영역 루트 `startsWith` 재확인. 클라이언트 파일명은 어느 단계에도 들어오지 않는다(`resolutionNeverDependsOnAClientFilename`가 고정).
|
||
|
||
`LocalAppendEngine`의 롤백 설계도 촘촘하다. 실패하면 누산 다이제스트를 **먼저 버리고**(이미 버려질 바이트를 흡수했으므로), `truncate` → `force` → `size` 재확인으로 물리 길이가 append 이전으로 돌아왔음을 **증명한 뒤에야** 원래 실패를 그대로 던진다. 증명하지 못하면 `AmbiguousCompletionException`으로 격상해 reconciliation에 넘긴다. 선언된 content length는 사후 검사가 아니라 **읽기 상한**으로 쓰이고(`buffer.limit(min(capacity, contentLength - appended))`), 잉여는 1바이트 probe read로 감지해 버린다.
|
||
|
||
`LocalBlockingContentStore`는 20곳 전부 `channels.*`(서술자 상대)를 쓰고 `Files.*`를 한 번도 부르지 않는다(`145-...` §8.2).
|
||
|
||
## 32. P3 — 발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 "근사에 불과하다"고 적은 사전검사다
|
||
|
||
`platform/local`에 남은 `java.nio.file.Files.*` 호출을 전수 조사했다(`145-...` §8.2). 대부분은 정당하다 — `SecureDirectoryWalk.openRoot`(문서가 "the one unavoidable pathname resolution"이라 적는 루트 열기), `LocalStorageCapabilityProbe`(startup probe, 격리된 probe 영역), `LocalOrphanScanAdapter`·`LocalStorageHealthAdapter`·`LocalStorageUsageProbe`(sub-scope 05).
|
||
|
||
문제는 **쓰기 경로에 남은 다섯 호출**이다.
|
||
|
||
```
|
||
AtomicMoveContentPublisher:53 Files.move(staging, target, ATOMIC_MOVE) ← 발행 rename
|
||
AtomicMoveContentPublisher:113 Files.exists(staging, NOFOLLOW_LINKS) ← 실패 분류
|
||
AtomicMoveContentPublisher:114 Files.exists(target, NOFOLLOW_LINKS) ← 실패 분류
|
||
ContentPublishVerification:53 Files.size(target) ← 발행 크기
|
||
MetadataPointerContentPublisher:107 Files.deleteIfExists(staging)
|
||
```
|
||
|
||
그리고 `AtomicMoveContentPublisher:50`이 그 rename 직전에 부르는 것은 `channels.requireNoSymlinkBetween(root, target.getParent())` — 즉 **경로 기반 사전검사**다. 그 메서드의 javadoc이 스스로를 이렇게 설명한다.
|
||
|
||
> **Retained for the capability probe**, which still reasons about pathnames. Production access no longer relies on it: descending descriptor by descriptor with `NOFOLLOW_LINKS` refuses a symlinked component by construction, **which a precheck could only ever approximate.**
|
||
|
||
즉 "production은 더 이상 이것에 의존하지 않는다"고 적힌 메서드를, 콘텐츠를 **보이게 만드는 바로 그 단계**가 유일한 보호로 쓴다. `SecureDirectoryWalk`의 "More checks only narrow the window; they never close it"이 겨냥한 패턴 그 자체다.
|
||
|
||
같은 불일치가 파일 길이에서도 보인다. `LocalAppendEngine.currentLength`는 여덟 줄짜리 javadoc으로 왜 `Files.size`가 틀렸는지 설명하고 `channels.readAttributes(root, staging)`를 쓴다 — "an attacker who swaps the parent for a symlink gets this check to report the size of their own file". `ContentPublishVerification.sizeOf`는 같은 질문에 `Files.size(target)`으로 답한다.
|
||
|
||
**판정: P3.** 실제 악용에는 스토리지 루트 **안쪽** 쓰기 권한이 필요하고, 이 leaf가 그 루트의 소유자·권한을 증명하는 것은 R2 경로(`LocalPersistentRootAttestor`)뿐이며 플랫폼 저장소 루트의 증명은 `app-bootstrap`의 startup validator 몫이다. 그래서 도달성은 배포 형상에 달려 있다. 심각도를 P3로 두는 이유는 그것이고, 그럼에도 기록하는 이유는 **모듈 자신의 문서가 이 패턴을 명시적으로 불충분하다고 선언했다**는 점이다. 수정은 발행 rename을 `SecureDirectoryWalk.inParentOf`로 옮겨 부모 서술자 상대 `move`를 쓰고, `sizeOf`를 `channels.readAttributes`로 바꾸는 것이다.
|
||
|
||
## 33. Confirmed — 두 발행 전략이 probe 결과로 선택되고, 각자 다른 실패를 다르게 분류한다
|
||
|
||
`selectPublisher`는 설정이 아니라 **probe가 증명한 것**에서 전략을 고른다. `ATOMIC_MOVE_REQUIRED`는 원자적 이동을 증명하지 못하면 fail-closed, `ATOMIC_MOVE_PREFERRED`는 pointer 발행으로 강등된다. `ContentPublisherTest`가 양쪽을 고정한다(`requiredAtomicModeFailsClosedWhenTheProbeCouldNotProveIt`, `preferredModeDegradesToPointerPublishWhenAtomicMoveIsUnproven`).
|
||
|
||
두 전략 모두 **`REPLACE_EXISTING`을 쓰지 않는다** — 기존 대상은 조용한 덮어쓰기가 아니라 충돌이다. 그리고 결과를 증명할 수 없으면 성공도 실패도 아닌 `AmbiguousCompletionException`이다.
|
||
|
||
`AtomicMoveContentPublisher.forceDirectoryEntries`의 근거가 특히 정확하다 — 스테이징 파일을 force하는 것은 **내용**을 지속시킬 뿐 그것을 가리키는 **디렉터리 엔트리**에 대해서는 아무 말도 하지 않는다. 크래시 후 객체가 완전히 쓰였으면서 동시에 두 디렉터리 어디에도 없고 메타데이터는 READY라고 말하는 상태가 가능하다. rename은 두 디렉터리를 바꾸므로 둘 다 sync하고, sync 실패는 무시가 아니라 ambiguous로 격상한다.
|
||
|
||
`MetadataPointerContentPublisher`는 복사 후 **디스크에서 다시 다이제스트를 계산해** 스테이지 다이제스트와 비교한다. `ContentPublishVerification`의 javadoc이 그 원칙을 적는다 — "recomputed from the bytes actually on disk rather than trusted from the streaming accumulator, so a publish can never advertise a hash the stored object does not have".
|
||
|
||
## 34. P3 — `TransferBufferPool.maxBorrowedBytes()`가 자기 회귀 test를 지목하는데 그 test가 읽지 않는다
|
||
|
||
`TransferBufferPool`은 대여 중 바이트의 최대치를 추적하고 javadoc에 이렇게 적는다.
|
||
|
||
> Peak simultaneously-borrowed bytes; **the bounded-memory regression asserts on this.**
|
||
|
||
`145-...` §8.4의 전수 검색에서 `maxBorrowedBytes`는 `TransferBufferPool.java` 세 줄에만 나타나고, `LocalAppendMemoryTest`에도 `LargeFileBoundedMemoryTest`에도 없다. 즉 회귀 test는 이 값을 읽지 않는다.
|
||
|
||
기능 자체는 옳게 동작한다 — `borrow`가 `bufferSize`만큼 증가시키고 `release`가 되돌리며, 최대치를 `accumulateAndGet(_, Math::max)`로 누적한다. 그리고 경계 자체(전송이 파일 크기에 비례해 메모리를 쓰지 않음)는 다른 방식으로 검증되고 있다. 문제는 javadoc이 존재하지 않는 결합을 서술한다는 것이고, 그 서술 때문에 이 계측이 지켜지고 있다고 읽힌다. **P3.**
|
||
|
||
## 35. Negative-space probes — sub-scope 04
|
||
|
||
- **8.1 reachability**: `platform/local`의 15개 타입이 public이고, leaf 밖에서는 `app-bootstrap`의 fileserver autoconfigure 5개 클래스가 참조한다. 나머지(`SecureDirectoryWalk`·`SafeFileChannelFactory`·`DefaultPhysicalPathResolver`·publisher 3종·`LocalUploadHandle` 등)는 package-private — `Path`가 SPI를 건너지 않는다는 주장이 가시성으로 성립.
|
||
- **8.2 조건부 형제**: 파일 길이를 묻는 두 방식(§32), 서술자 상대 vs 경로 기반 쓰기(§32).
|
||
- **8.3 중복 mechanism**: 두 발행 전략은 중복이 아니라 probe 결과로 배타 선택되고 `usesAtomicMove()`로 자기 성격을 보고한다(§33).
|
||
- **8.4 문서/동작 대조**: `maxBorrowedBytes` javadoc의 회귀 test 결합 부재(§34). `LocalCopyContentGateway`의 "A copy is not a link" 근거와 실제 스테이징 경유 복사 구현 일치. `LocalZeroCopyDownloadGateway`의 짧은 전송 재시도와 부분 전송 보고 일치.
|
||
|
||
## 36. Sub-scope 04 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P3** | 발행 rename(`Files.move`)과 그 실패 분류(`Files.exists`), 발행 크기(`Files.size`)가 경로 기반이고, 유일한 보호는 이 모듈이 "a precheck could only ever approximate"라고 적은 `requireNoSymlinkBetween`이다 | 스토리지 루트 안쪽에 쓰기 권한을 가진 주체 — 루트 증명은 배포 형상에 달려 있다 |
|
||
| **P3** | `TransferBufferPool.maxBorrowedBytes()`의 javadoc이 "the bounded-memory regression asserts on this"라고 적지만 어떤 test도 읽지 않는다 | 계측/문서 |
|
||
|
||
## 37. Sub-scope 04 완료 조건
|
||
|
||
- denominator 30 / 30 FULL_READ (`145-...` OWNED FILES)
|
||
- §8.1~§8.4 네 종 probe 수행, `platform/local`의 `Files.*` 호출을 전수 조사해 정당한 것과 남은 것을 분리
|
||
- 두 finding 모두 정적으로 결정 가능(호출 지점과 javadoc 대조)하여 실행 probe 불필요
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
## 38. Sub-scope 05 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **29 / 29 FULL_READ**
|
||
> 범위: `platform/local` 실패분류·probe·health·orphan 10 + `platform/verification` 10 + `platform/security` 2 + `platform/audit` 2 (main 24) + 전용 test 5
|
||
> 역할: 콘텐츠 **검증 사슬**, 역할 기반 인가, 감사 기록, 그리고 파일시스템 실패를 "일어났는가"로 분류하는 계층
|
||
|
||
manifest·정적 probe·실행 probe: `evidence/raw/146-fileserver-verification-security-audit-probes.txt`.
|
||
|
||
## 39. P2 확정 — §4의 README 주장이 여덟 개의 port 구현과 여덟 개의 bean 앞에서 성립하지 않는다
|
||
|
||
sub-scope 01(§4)에서 이월한 판정을 여기서 닫는다. README:103–105는 audit·readiness/health·reconciliation/reaping·quota가 "not implemented"이고 "**No setting or bean for those capabilities is exposed**"라고 적는다. 실제로는 이 sub-scope의 타입들이 `application-core` port를 구현하고, `app-bootstrap`이 그 전부를 bean으로 만든다(`146-...` §8.1).
|
||
|
||
| port | 구현 | bean 생성 |
|
||
|---|---|---|
|
||
| `AdminAuditPort` | `StructuredAdminAuditAdapter` | `FileserverSecurityConfiguration:71` |
|
||
| `FileserverAuditPort` | `StructuredFileserverAuditAdapter` | `:77` |
|
||
| `FileAccessPolicy` | `RoleBasedFileAccessPolicy` / `UnenforcedFileAccessPolicy` | `:51` / `:90` |
|
||
| `StorageHealthPort` | `LocalStorageHealthAdapter` | `FileserverStorageConfiguration:179` |
|
||
| `OrphanScanPort` | `LocalOrphanScanAdapter` | `:189` |
|
||
| `StorageUsageProbe` | `LocalStorageUsageProbe` | `:196` |
|
||
| `ReconciliationContentProbe` | `LocalReconciliationContentProbe` | `:203` |
|
||
|
||
스텁이 아니다. 감사 어댑터는 전용 로거 카테고리(`dev.caskeleton.fileserver.audit`)로 쓰고, 실패한 동작을 성공과 **같은 레벨로** 남긴다("a refused force-delete is the entry a reviewer most needs to find"). health 어댑터는 원자적 이동 가능 여부를 설정이 아니라 **probe가 증명한 사실**에서 보고한다. usage probe는 매 호출마다 `FileStore`를 다시 읽고, 읽을 수 없으면 0%도 100%도 아닌 **빈 답**을 낸다("a synthetic 0% would silently disable the high-water guard, and a synthetic 100% would take the capability down over a failed syscall").
|
||
|
||
즉 코드 쪽은 잘 만들어져 있고, 틀린 것은 README 한 문단이다. §4에서 적은 대로 이것들은 R2 publication이 아니라 HTTP Fileserver 플랫폼의 부품이지만, 그 문단에는 한정어가 없다. **P2 확정.**
|
||
|
||
## 40. P2 — scriptable 콘텐츠 탐지가 접두사 **시작**에만 고정돼 있어 BOM·NUL·주석으로 우회된다
|
||
|
||
`ScriptableContentPolicy`의 javadoc은 이 검사의 목적을 분명히 적는다.
|
||
|
||
> Guards content that a browser would execute if it were ever served inline. **Detection is on content, not on the claimed type or the extension, because both are attacker controlled.**
|
||
|
||
구현은 1,024바이트 접두사를 소문자로 만든 뒤 `stripLeading()`하고, 여섯 마커(`<!doctype html`, `<html`, `<script`, `<svg`, `<?xml`, `<!entity`) 중 하나로 **시작하는지**만 본다.
|
||
|
||
hermetic 실행 probe로 실제 판정을 측정했다(`146-...` EXECUTION PROBE, `inlineSafeProfile=false` 강제 프로파일).
|
||
|
||
```
|
||
PROBE plain <script> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||
PROBE plain <html> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||
PROBE leading whitespace + <html> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||
PROBE uppercase <SCRIPT> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||
PROBE <svg onload> -> QUARANTINE / SCRIPTABLE_CONTENT
|
||
PROBE UTF-8 BOM + <html> -> ACCEPT / NO_SCRIPTABLE_CONTENT ←
|
||
PROBE HTML comment then <script> -> ACCEPT / NO_SCRIPTABLE_CONTENT ←
|
||
PROBE NUL byte then <html> -> ACCEPT / NO_SCRIPTABLE_CONTENT ←
|
||
PROBE plain text -> ACCEPT / NO_SCRIPTABLE_CONTENT
|
||
```
|
||
|
||
세 가지가 통과한다. `String.stripLeading()`은 `Character.isWhitespace`만 제거하므로 **UTF-8 BOM(U+FEFF)도 NUL도 지우지 않고**, 선행 HTML 주석은 어떤 마커로도 시작하지 않는다. 셋 다 브라우저는 HTML로 렌더링한다 — BOM 접두 HTML은 이국적인 우회가 아니라 여러 편집기의 기본 출력이다.
|
||
|
||
형제 검증기와의 대비가 판정을 굳힌다. `MediaTypeVerifier`는 매직바이트를 접두사 **시작**에서 비교하는데, 그것은 시그니처의 정의가 파일 선두이므로 옳다. scriptable 마커는 시그니처가 아니라 **브라우저가 스니핑하는 패턴**이고, 브라우저는 선두 고정 매칭을 하지 않는다. 같은 "접두사 시작 비교"가 한쪽에서는 정확하고 다른 쪽에서는 우회 가능하다.
|
||
|
||
**판정: P2.** `inlineSafeProfile=false`인 배포에서 도달 가능하고, 그 프로파일이 바로 격리를 강제하려는 설정이다. 수정은 `startsWith`를 접두사 **탐색**으로 바꾸고, 매칭 전에 BOM·NUL·제어바이트를 제거하는 것이다. (완화 요인: `MediaTypeVerifier`가 claimed 타입과 감지 타입의 불일치를 별도로 격리하므로, `Content-Type: text/html`을 선언한 업로드는 다른 경로로 걸린다. 타입을 선언하지 않거나 `application/octet-stream`을 선언하면 걸리지 않는다.)
|
||
|
||
## 41. Confirmed — 검증 사슬의 합성이 fail-closed다
|
||
|
||
`VerificationCoordinator`는 검증기마다 시한을 두고, **timeout·interrupt·예외를 전부 `RETRY`로** 만든다 — `ACCEPT`가 아니다. javadoc이 이유를 적는다: "an unavailable scanner can never publish content by failing open."
|
||
|
||
`VerificationPolicyCombiner`의 우선순위는 `REJECT > QUARANTINE > RETRY > ACCEPT`이고, **`RETRY`가 `ACCEPT`보다 높다**는 것이 핵심이다 — 답하지 못한 검증기가 답한 검증기들에게 조용히 덮이지 않는다. 결과가 비면 `NO_VERIFIER_ANSWERED` → `RETRY`다. `REJECT`에 도달하면 뒤 검증기를 건너뛴다("A reject cannot be overturned").
|
||
|
||
개별 검증기도 방향이 옳다. `LengthVerifier`가 먼저 돌아 정책이 이미 배제한 콘텐츠에 뒤 검증기가 일하지 않게 하고, `MediaTypeVerifier`는 시그니처 일치를 **안전 판정으로 쓰지 않고** 기록할 타입만 정하며 claimed와 detected의 불일치를 격리한다. `FilenamePolicyVerifier`는 저장된 이름을 다시 sanitize해서 달라지면 거부가 아니라 **격리**한다 — 미정제 텍스트가 메타데이터 저장소에 들어갔다는 뜻이므로 콘텐츠 문제가 아니라 결함이기 때문이다.
|
||
|
||
`LocalVerificationContentReader`는 스테이징(업로드 중)과 발행 콘텐츠(재검증) 양쪽을 읽는다 — "otherwise re-verifying a quarantined file would silently inspect nothing and accept it".
|
||
|
||
## 42. Confirmed — 인가와 감사가 정보를 흘리지 않는다
|
||
|
||
`RoleBasedFileAccessPolicy`는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접는다. 근거가 적혀 있다 — 연산별 역할 맵은 `COPY`를 주고 `CREATE`를 안 주는 조합을 허용하는데 "a copy creates a file"이므로 제한처럼 보이고 제한이 아니다. **admin은 write를 상속하지 않는다** — 삭제할 수 있다는 이유로 force-delete까지 되면 감사되는 관리 평면이 일반 데이터 평면으로 도달 가능해진다. 빈 admin 역할 집합은 생성자가 거부한다("would leave the management plane unreachable rather than protected").
|
||
|
||
거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다 — "a denial that reported what was missing would turn every 403 into a readable description of the role model".
|
||
|
||
`UnenforcedFileAccessPolicy`의 설계도 기록할 만하다. 이름 자체가 장치다 — composition root가 **타입 이름으로 매치해** production startup을 거부한다. "A permissive default that looked like a real policy would ship as one."
|
||
|
||
실패 메시지 위생도 일관된다. `LocalStorageFailures`의 어떤 메시지도 경로·마운트·루트를 담지 않고, 감사 어댑터가 쓰는 필드는 전부 지문·코드·불투명 식별자다.
|
||
|
||
## 43. Confirmed — 실패를 "재시도 안전한가"로 분류한다
|
||
|
||
`AmbiguousFilesystemOperationDetector`는 `IOException`을 네 결과로 나눈다(`NOT_SENT` / `DEFINITELY_REJECTED` / `AMBIGUOUS_COMPLETION` / `RECONCILIATION_REQUIRED`). 기본값이 보수적이다 — 인식하지 못한 실패는 **변경 연산이면 ambiguous**다. javadoc이 비대칭을 적는다: "the cost of a wrong 'safe to retry' is a corrupted object, while the cost of a wrong 'ambiguous' is one reconciliation entry."
|
||
|
||
`mutating` 인자로 순수 읽기는 결코 ambiguous가 되지 않게 하고, stale handle은 변경 연산일 때 `RECONCILIATION_REQUIRED`로 격상한다 — 에러만으로는 결과를 알 수 없으므로 물리 증거를 다시 읽어야 한다.
|
||
|
||
다만 `isStaleHandle`·`isLostResponse`와 `FilesystemFailureClassifier.isOutOfSpace`가 **메시지 텍스트 매칭**에 의존한다("stale file handle", "estale", "timed out", "No space left on device", "Disk quota exceeded"). 후자에는 주석이 붙어 있다 — "The JDK has no dedicated exception for this, so the reason text is the only available signal." 로케일이나 JDK 판본에 따라 문구가 달라지면 분류가 기본값으로 떨어지는데, 기본값이 보수적(변경 연산 → ambiguous)이므로 안전한 방향이다. 기록만 한다.
|
||
|
||
## 44. Negative-space probes — sub-scope 05
|
||
|
||
- **8.1 reachability**: 8개 port 구현과 app-bootstrap의 8개 bean 생성 지점을 직접 확인해 §4를 확정(§39).
|
||
- **8.2 계약 ↔ 구현**: scriptable 탐지의 선언("detection is on content")과 실제 매칭 범위(접두사 시작 고정)의 격차 — 실행 probe로 확정(§40).
|
||
- **8.2b 조건부 형제**: 같은 "접두사 시작 비교"가 `MediaTypeVerifier`에서는 정확하고 `ScriptableContentPolicy`에서는 우회 가능(§40).
|
||
- **8.3 fail-closed 합성**: coordinator의 timeout/예외 → `RETRY`, combiner의 `RETRY > ACCEPT` 우선순위(§41).
|
||
- **8.4 위생/문서**: 실패·감사 메시지에 경로 없음, 거부 메시지에 역할 없음(§42). 메시지 텍스트 매칭 의존과 그 보수적 기본값(§43).
|
||
|
||
## 45. Sub-scope 05 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| **P2** | README:105 "No setting or bean for those capabilities is exposed"가 audit·health·reaping·quota 네 능력에 대해 사실과 다르다 — 8개 port 구현과 app-bootstrap의 8개 bean으로 확정 | 이 문단으로 능력 유무를 판단하는 독자 |
|
||
| **P2** | `ScriptableContentPolicy`가 마커를 접두사 **시작**에서만 찾아, UTF-8 BOM·NUL·선행 HTML 주석이 붙은 실행 가능 콘텐츠를 ACCEPT한다 (실행 probe 3건) | `inlineSafeProfile=false`이고 claimed 타입을 선언하지 않는 업로드 |
|
||
| **P3/기록** | 실패 분류가 예외 메시지 텍스트("stale file handle", "timed out", "No space left on device")에 의존한다 — 문구가 달라지면 보수적 기본값으로 떨어지므로 안전한 방향 | 로케일/JDK 판본이 다른 배포 |
|
||
|
||
## 46. Sub-scope 05 완료 조건
|
||
|
||
- denominator 29 / 29 FULL_READ (`146-...` OWNED FILES)
|
||
- §8.1~§8.4 네 종 probe 수행, sub-scope 01에서 이월한 P2를 port 구현·bean 생성 지점으로 확정
|
||
- scriptable 탐지 우회를 hermetic 실행 probe 3건으로 확정
|
||
- 임시 probe class 1개 추가 후 제거, `git status --short` = 0, 소스 미변경
|
||
|
||
---
|
||
|
||
## 47. Sub-scope 06 범위와 denominator
|
||
|
||
> 내부 상태: COMPLETE — **15 / 15 FULL_READ**
|
||
> 범위: `LocalPersistentPayloadOperations` 1,101 + `StreamingCsvEncoder` 190 + `FilesystemCsvExportAdapter` 188 (main 3) + 전용 test 3 + testkit 9
|
||
> 역할: R2 payload의 보안 경계, RFC-4180 스트리밍 인코딩(수식 완화 포함), 그리고 store 계약·크래시 행렬·NFS 모호성 testkit
|
||
|
||
manifest와 probe: `evidence/raw/147-fileserver-payload-testkit-probes.txt`.
|
||
|
||
## 48. Confirmed — payload 계층이 자신의 잔여 위험을 먼저 선언한다
|
||
|
||
`LocalPersistentPayloadOperations`의 클래스 javadoc이 무엇이 서술자 상대이고 무엇이 아닌지를 앞에서 밝힌다.
|
||
|
||
> Reads, writes, and exact deletes use `SecureDirectoryStream`; **the JDK's missing relative hard-link, directory-create, and directory-force primitives are bracketed by attested identity checks in this class.**
|
||
|
||
전수 검사가 그 서술과 일치한다(`147-...` §8.2). 이 파일의 `Files.*` 호출은 정확히 그 셋 — `Files.createLink`(:941), `Files.createDirectory`(:802), 그리고 force/stat/FileStore 조회 — 뿐이고, 각각 앞뒤로 `fileKey`·소유자·권한·FileStore 재확인이 붙는다. JDK가 `linkat`/`mkdirat`를 노출하지 않으므로 서술자 상대 대응물이 없고, 그 사실을 숨기는 대신 적었다.
|
||
|
||
**이것이 §32와의 차이다.** 여기서는 잔여 경로 연산이 (a) 문서에 선언되고 (b) identity 검사로 감싸인다. `AtomicMoveContentPublisher`의 발행 rename은 (a) 어디에도 선언되지 않고 (b) 같은 모듈이 "a precheck could only ever approximate"라고 적은 사전검사 하나로만 보호된다. 같은 저장소가 같은 문제를 한 번은 정직하게, 한 번은 그렇지 않게 다룬 대비다.
|
||
|
||
## 49. Confirmed — CSV 인코더가 스트리밍이고 세 가지 상한을 동시에 건다
|
||
|
||
`StreamingCsvEncoder`는 행 단위로 쓰고 즉시 다이제스트에 넣는다. 상한이 셋이다 — 행 수(`maximumRows`), 누적 바이트(`maximumBytes`, `bytesWritten > maximumBytes - bytes.length`로 오버플로 없이 검사), 그리고 **컬럼별 UTF-8 바이트**(`column.maximumUtf8Bytes()`). 셀 타입이 스키마와 다르면 거부하고, `null`은 컬럼이 nullable일 때만 빈 문자열이 된다.
|
||
|
||
수식 주입 완화는 세 정책으로 갈린다 — `ALLOW`/`MITIGATE`(`'` 접두, 카운트 증가)/`REJECT`. 후보 판정은 첫 문자가 `=`, `+`, `-`, `@`, `\t`, `\r`인지다(OWASP 권고 집합). `formulaMitigatedCount`가 receipt와 control record까지 전달되므로(§13의 `requireFormulaCountWithinCells`) 완화가 일어났다는 사실이 감사 가능한 값으로 남는다.
|
||
|
||
`checkpoint()`가 매 행 앞뒤로 스레드 인터럽트를 확인해 협력적 취소를 지원한다.
|
||
|
||
R1 legacy 어댑터도 두 결함을 이미 고쳤다고 주석에 남긴다 — 전체를 `StringBuilder`에 모으던 방식(백만 행이면 OOM)을 bounded writer 스트리밍으로, `Files.write`의 조용한 truncate를 `CREATE_NEW`로. 다만 R1이 "a stand-in for NFS/SFTP"라고 자칭하는 것은 CLAUDE.md의 "Advertising `shared-mounted`/NFS, SFTP … as implemented" 금지와 나란히 두면 표현이 조심스럽다("stand-in"은 구현 주장이 아니다). 결함으로 올리지 않는다.
|
||
|
||
## 50. Confirmed — testkit이 크래시 지점을 열거해 전수 검증한다
|
||
|
||
`CrashRecoveryMatrixTest`는 `@EnumSource(CrashPoint.class)`로 **모든** 크래시 지점에 대해 두 불변식을 건다.
|
||
|
||
- `aPublishedObjectIsAlwaysCompleteAndDigestMatched`
|
||
- `aCrashNeverLeavesAPartialObjectUnderThePublishedKey`
|
||
|
||
즉 "어떤 지점에서 죽어도 발행된 키 아래에 부분 객체가 없다"를 지점별로 확인한다. `AFTER_CREATE`·`DURING_APPEND`·`AFTER_APPEND_COMMIT`·`BEFORE_PUBLISH`·`AFTER_METADATA_BEFORE_QUOTA` 등이 열거돼 있어, 새 지점을 추가하면 두 test가 자동으로 그것을 포함한다.
|
||
|
||
`ContentStoreContract`는 추상 계약이고 `LocalContentStoreContractTest`(원자적 이동)와 `MetadataPointerContentStoreContractTest`(포인터 발행)가 각각 상속한다 — **두 발행 전략이 같은 계약을 통과해야 한다**는 것을 구조로 강제한다. 계약 항목도 성질 중심이다: 왕복, 다중 append의 다이제스트가 모든 바이트를 덮는지, 오프셋 불일치가 객체를 건드리지 않고 거부되는지, 범위 읽기가 정확히 요청 바이트만 반환하는지, 선언 다이제스트/길이 불일치가 finalize에서 거부되는지, 부재 객체 삭제가 멱등 성공이면서 divergence를 보고하는지, store가 **증명한** capability만 보고하는지, 두 업로드가 물리 키를 공유하지 않는지.
|
||
|
||
`FileserverCrashScenarioMain`은 별도 프로세스로 fork되는 진입점이고(§sub-scope 02의 `LocalPersistentCrashRecoveryTest`가 사용), `NfsAmbiguityIntegrationTest`·`NfsTestEnvironment`·`PvcCertificationDescriptor`는 환경이 있을 때만 도는 자격 검증 fixture다.
|
||
|
||
## 51. Negative-space probes — sub-scope 06
|
||
|
||
- **8.1 reachability**: 세 main 타입 모두 leaf 밖 참조 0(`147-...` §8.1). `FilesystemCsvExportAdapter`만 `public class`인데(다른 것은 package-private/final) 외부 참조가 없으므로 가시성이 필요보다 넓다 — 기록만 한다.
|
||
- **8.2 계약 ↔ 구현**: payload 계층의 잔여 경로 연산 선언과 실제 호출 지점 일치(§48).
|
||
- **8.3 중복 mechanism**: R1 legacy 인코딩과 R2 스트리밍 인코더 — 별개 포트, 별개 selector, 공유 없음(§49).
|
||
- **8.4 문서/커버리지**: 크래시 지점 enum 전수 순회(§50), 두 발행 전략의 공통 계약 상속(§50).
|
||
|
||
## 52. Sub-scope 06 findings backlog
|
||
|
||
| 우선순위 | finding | reachability |
|
||
|---|---|---|
|
||
| — | **없음.** payload 계층은 잔여 위험을 선언하고 감쌌고, 인코더의 상한·수식 정책·취소는 전부 값으로 관측 가능하며, testkit은 크래시 지점을 열거해 전수 검증한다 | — |
|
||
|
||
## 53. Sub-scope 06 완료 조건
|
||
|
||
- denominator 15 / 15 FULL_READ (`147-...` OWNED FILES) — main 1,479 LOC 전수 판독
|
||
- §8.1~§8.4 네 종 probe 수행
|
||
- 실행 probe 불필요 — 판정 지점이 정적으로 결정 가능
|
||
- 소스 미변경
|
||
|
||
---
|
||
|
||
## 54. 모듈 원장 대조
|
||
|
||
`§0`의 denominator 119를 하위 범위 실측과 대조한다.
|
||
|
||
| # | 하위 범위 | main | test | 합 | 근거 |
|
||
|---|---|---|---|---|---|
|
||
| 1 | governance / config / activation | 7 | 2 | 13 (governance 4 포함) | `142` |
|
||
| 2 | control plane + record codec + recovery verifier | 3 | 3 | 6 | `143` |
|
||
| 3 | publication | 19 | 7 | 26 | `144` |
|
||
| 4 | `platform/local` IO · gateway · store/publisher | 22 | 8 | 30 | `145` |
|
||
| 5 | `platform/local` 실패·probe·health·orphan + verification + security + audit | 24 | 5 | 29 | `146` |
|
||
| 6 | payload · CSV · testkit | 3 | 12 | 15 | `147` |
|
||
| | **합계** | **78** | **37** | **119** | |
|
||
|
||
- main 78 (12,707 LOC), test 37 (12,043 LOC), governance 4. 총 119 tracked files.
|
||
- **unclassified 0, structural-only 0, excluded 0.** 6개 하위 범위 모두 FULL_READ.
|
||
|
||
## 55. 모듈 findings 종합
|
||
|
||
| 우선순위 | 개수 | 항목 |
|
||
|---|---|---|
|
||
| **P2** | 3 | §4·§39 README:105의 "노출된 setting도 bean도 없다"가 audit·health·reaping·quota 넷에 대해 사실과 다름 (port 구현 8, bean 8) · §40 `ScriptableContentPolicy`가 BOM·NUL·선행 주석으로 우회됨 (실행 probe 3건) |
|
||
| **P3** | 5 | §5 R1의 `ignoreUnknownFields` 부재 · §5 R1/R2 루트 경로 규칙 비대칭 · §6 문서의 기본값 위치·test 목록 오류 · §32 발행 rename의 경로 기반 연산 · §34 `maxBorrowedBytes`의 서술과 실제 결합 부재 |
|
||
| **P3/기록** | 1 | §43 실패 분류의 예외 메시지 텍스트 의존 |
|
||
|
||
**결함이 없는 하위 범위가 셋이다**(02·03·06). 이 leaf의 코드 품질은 지금까지 분석한 모듈 중 가장 높은 축에 속한다 — canonical 왕복 검증, 인접 전이 행렬, 서술자 상대 파일시스템 접근, 크래시 지점 전수 순회, fail-closed 검증 합성이 모두 실제로 구현돼 있고 test가 그것을 성질로 고정한다.
|
||
|
||
반복된 형태는 둘이다.
|
||
|
||
1. **문서가 코드보다 좁게 또는 넓게 말한다.** README의 guarantee-boundary 문단이 같은 저장소가 만드는 bean을 "없다"고 하고(§4·§39), 기본값의 위치와 test 목록이 어긋나며(§6), 계측의 javadoc이 존재하지 않는 결합을 서술한다(§34). 코드는 대체로 옳고 서술이 뒤처졌다.
|
||
2. **자기 규칙의 예외가 선언될 때와 그렇지 않을 때.** `LocalPersistentPayloadOperations`는 JDK가 서술자 상대 원시연산을 주지 않는 세 곳을 **먼저 선언하고** identity 검사로 감쌌다(§48). `AtomicMoveContentPublisher`의 발행 rename은 같은 성격의 예외인데 선언되지 않고, 보호는 이 모듈이 스스로 "근사에 불과하다"고 적은 사전검사 하나다(§32). 규칙이 아니라 **예외를 다루는 방식**이 두 곳에서 다르다.
|
||
|
||
## 56. 모듈 완료 조건
|
||
|
||
- denominator **119 / 119 FULL_READ** — 6개 하위 범위 전부 COMPLETE(§54)
|
||
- 하위 범위마다 §8.1~§8.4 네 종 negative-space probe 수행, 증거는 `evidence/raw/141`–`147`
|
||
- 정적으로 결정 불가한 지점을 실행 probe로 확정: `146`(scriptable 탐지 우회 3건)
|
||
- sub-scope 01에서 제기한 P2를 sub-scope 05에서 port 구현·bean 생성 지점으로 확정 — 이월과 종결을 원장에 남김
|
||
- 임시 probe class 1개 추가 후 제거, `git status --short` = 0, 소스 미변경
|
||
|
||
---
|
||
|
||
## 57. 실행 검증과 분석 환경 제약
|
||
|
||
HEAD에서 focused suite를 돌린 결과와 그 해석을 남긴다(`evidence/raw/148-fileserver-suite-verification.txt`).
|
||
|
||
컨테이너 기본 로케일에서 `:adapter:outbound:fileserver:test`는 **398 tests / 1 failed / 3 skipped**로 끝난다. 실패한 것은 `LocalPersistentPayloadOperationsTest.inspectsLegacyRootArtifactByBoundedNoFollowStreamingWithoutRewritingIt()` 하나이고, 원인은 다음이다.
|
||
|
||
```
|
||
java.nio.file.InvalidPathException: Malformed input or input contains unmappable
|
||
characters: 월간 export -- legacy 01.csv
|
||
at java.base/sun.nio.fs.UnixPath.encode(UnixPath.java:129)
|
||
at LocalPersistentPayloadOperationsTest.java:331
|
||
```
|
||
|
||
분석 컨테이너의 로케일이 `POSIX`이고 `sun.jnu.encoding=ANSI_X3.4-1968`이라, test fixture가 **자기 경로를 만드는 단계**(`Path.resolve`, 331행)에서 한국어 파일명을 인코딩하지 못한다. adapter production 코드는 실행되지도 않는다.
|
||
|
||
같은 revision을 `LANG=C.UTF-8`로 다시 돌리면 **BUILD SUCCESSFUL**이다. 소스는 한 줄도 바꾸지 않았다.
|
||
|
||
**판정: 분석 환경 제약이지 저장소 결함이 아니다.** (JPA scope에서 PostgreSQL TLS lane을 같은 방식으로 분류한 것과 동일한 형태다. 다만 이 leaf는 비-ASCII 소스를 test fixture에 쓰므로, `adapter/outbound/identifier`가 `build.gradle`에서 UTF-8 인코딩을 명시적으로 고정한 것과 같은 조치를 이 leaf는 하지 않았다는 점은 기록해 둔다 — 컴파일 인코딩과 런타임 `sun.jnu.encoding`은 별개 문제이므로 결함으로 올리지는 않는다.)
|
||
|
||
## Source anchors
|
||
|
||
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **69개** (main 55 · test 13 · 기타 1).
|
||
|
||
```
|
||
src/adapter/outbound/fileserver/build.gradle
|
||
src/config/architecture/modules.json (adapter-outbound-fileserver 항목)
|
||
|
||
main:
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/CompiledFileDestination.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/DurablePublicationRecord.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportConfig.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileExportSettings.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationCanonicalDigests.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublicationProvider.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilePublishRequestFingerprint.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverActivationValidator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverBindingCompiler.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverControlRecordCodec.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Config.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FileserverR2Validation.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/FilesystemCsvExportAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalFilePublicationAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentControlPlane.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperations.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPublicationProvider.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRecoveryVerifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentRootAttestor.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/LocalPublicationJournal.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/PrivateFileManifest.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/PublishedReferenceRecord.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/RoutingFilePublicationAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/StreamingCsvEncoder.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredAdminAuditAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/audit/StructuredFileserverAuditAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AmbiguousFilesystemOperationDetector.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/AtomicMoveContentPublisher.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublishVerification.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/DefaultPhysicalPathResolver.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/FilesystemFailureClassifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendEngine.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalBlockingContentStore.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalCopyContentGateway.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalOrphanScanAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalReconciliationContentProbe.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageCapabilityProbe.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageFailures.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageHealthAdapter.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalStorageUsageProbe.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalUploadHandle.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalZeroCopyDownloadGateway.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/MetadataPointerContentPublisher.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SafeFileChannelFactory.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/SecureDirectoryWalk.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/TransferBufferPool.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/RoleBasedFileAccessPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/security/UnenforcedFileAccessPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/FilenamePolicyVerifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LengthVerifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/LocalVerificationContentReader.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/MediaTypeVerifier.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/ScriptableContentPolicy.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationCoordinator.java
|
||
src/main/java/dev/caskeleton/adapter/outbound/fileserver/platform/verification/VerificationPolicyCombiner.java
|
||
|
||
test:
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/FileserverCrashScenarioMain.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentCrashRecoveryTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/LocalPersistentPayloadOperationsTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/ContentPublisherTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/platform/local/LocalAppendMemoryTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/ContentStoreContract.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/CrashRecoveryMatrixTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LargeFileBoundedMemoryTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/LocalContentStoreContractTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/MetadataPointerContentStoreContractTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsAmbiguityIntegrationTest.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/NfsTestEnvironment.java
|
||
src/test/java/dev/caskeleton/adapter/outbound/fileserver/testkit/PvcCertificationDescriptor.java
|
||
|
||
기타:
|
||
src/build.gradle
|
||
|
||
해석되지 않은 인용 (9종) — 외부 타입·문서상 약칭 등:
|
||
app-bootstrap/application.yml
|
||
evidence/raw/141-fileserver-module-inventory.txt
|
||
evidence/raw/142-fileserver-config-activation-probes.txt
|
||
evidence/raw/143-fileserver-control-plane-probes.txt
|
||
evidence/raw/144-fileserver-publication-probes.txt
|
||
evidence/raw/145-fileserver-local-io-probes.txt
|
||
evidence/raw/146-fileserver-verification-security-audit-probes.txt
|
||
evidence/raw/147-fileserver-payload-testkit-probes.txt
|
||
evidence/raw/148-fileserver-suite-verification.txt
|
||
|
||
```
|