# 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::` 형식을 쓴다. 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()`하고, 여섯 마커(` -> QUARANTINE / SCRIPTABLE_CONTENT PROBE plain -> QUARANTINE / SCRIPTABLE_CONTENT PROBE leading whitespace + -> QUARANTINE / SCRIPTABLE_CONTENT PROBE uppercase