diff --git a/src/claude.rs b/src/claude.rs index 51c55fc..4b0e1dd 100644 --- a/src/claude.rs +++ b/src/claude.rs @@ -364,19 +364,35 @@ fn is_safe_base_path(base_path: &str) -> bool { .any(|component| matches!(component, Component::ParentDir)) } +/// branch명 byte length 상한(방어적 상한, FP`)로 저장되므로 정상 branch명은 +/// APFS NAME_MAX(255)를 넘을 수 없어 256B를 절대 초과하지 않는다. 따라서 256B 초과는 정상 git이 +/// 만들 수 없는 손상/조작된 `.git/HEAD` 신호이며, 표시를 거부해 false-positive(틀린 branch)보다 +/// false-negative(빈 pill)를 택하는 방어적 상한이다(FP` 형식의 HEAD에서 추출한 ``. detached HEAD(직접 SHA)나 -/// 읽기 실패 시 `None`. 부재/실패에 안전(절대 패닉하지 않음). +/// `ref: refs/heads/` 형식의 HEAD에서 추출한 ``. 다음 4원인 중 하나라도 해당하면 +/// `None`을 반환한다(부재/실패에 안전 — 절대 패닉하지 않음): +/// 1. `.git/HEAD` 부재 또는 읽기 실패(canonicalize/read 실패). +/// 2. detached HEAD(`ref:` 접두 없이 SHA 직접 기록). +/// 3. 외부향 심볼릭 HEAD(canonicalize 결과가 `.git/HEAD`로 끝나지 않음 — 누출 방어 위반). +/// 4. branch명이 비었거나 제어문자를 포함하거나 [`MAX_BRANCH_LEN`]을 초과. /// /// # 주의 -/// branch명은 제어문자 미포함만 허용한다(터미널/status 인젝션 방어). 신뢰 불가 `.git/HEAD`가 -/// ESC/개행/CR 등 제어문자가 섞인 branch명을 담으면 oneline SGR/cmux pill로 그대로 렌더돼 -/// 인젝션이 되므로, 정상 git branch명이 절대 갖지 않는 제어문자를 source chokepoint에서 거부한다. +/// - branch명은 제어문자 미포함만 허용한다(터미널/status 인젝션 방어). 신뢰 불가 `.git/HEAD`가 +/// ESC/개행/CR 등 제어문자가 섞인 branch명을 담으면 oneline SGR/cmux pill로 그대로 렌더돼 +/// 인젝션이 되므로, 정상 git branch명이 절대 갖지 않는 제어문자를 source chokepoint에서 거부한다. +/// - 심볼릭 `.git` 추종은 의도된 표준 git 동작 — canonicalize 가드는 결과가 `.git/HEAD`로 끝나는지만 +/// 확인(외부향 누출 차단), 추종 자체는 허용한다. +/// - 동기 fs read는 의도적 — `/.git/HEAD` 단일 소파일을 1회 read한다. 느린 네트워크 마운트 +/// (NFS 등)에서 status 렌더가 블록될 수 있다(알려진 트레이드오프). 완화(timeout/캐시)는 future work. fn read_branch_from_git_dir(base_path: &str) -> Option { use std::path::Path; // 표준 워크트리는 `/.git/HEAD`. (linked worktree의 gitfile 케이스는 v1 범위 밖.) @@ -395,7 +411,8 @@ fn read_branch_from_git_dir(base_path: &str) -> Option { let branch = trimmed.strip_prefix("ref: refs/heads/")?; // 인젝션 방어: 신뢰 불가 HEAD 내용에 제어문자(ESC/개행/CR/기타 C0·DEL)가 섞이면 거부한다. // 정상 git branch명은 제어문자를 절대 갖지 않으므로 정상 케이스 회귀는 0이다. - if branch.is_empty() || branch.chars().any(char::is_control) { + // SECURITY: 256B 초과 branch명 = 손상/조작된 .git/HEAD 신호 → 표시 거부(FP MAX_BRANCH_LEN || branch.chars().any(char::is_control) { None } else { Some(branch.to_string()) @@ -620,23 +637,86 @@ struct RawLtermInput { mod tests { use super::*; - /// 호출마다 고유한 비존재 절대 temp 경로를 반환한다(테스트 격리). + /// 테스트용 고유 임시 디렉터리 경로 + RAII 정리 가드. + /// Drop에서 remove_dir_all로 정리해 패닉(단언 실패) 시에도 누수 0. + /// 주의: unwind 전제 — `[profile.test] panic="abort"` 도입 시 Drop 미실행으로 무효(현재 Cargo.toml은 unwind 기본). + struct TestDir(std::path::PathBuf); + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } + } + impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } + } + + /// 호출마다 고유한 비존재 절대 temp 경로를 RAII 가드로 감싸 반환한다(테스트 격리). /// /// # 인자 /// - `label`: 경로를 사람이 식별하기 위한 라벨(테스트 의도 표시). /// /// # 반환 - /// `/understatus-