Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 154 additions & 32 deletions src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,19 +364,35 @@ fn is_safe_base_path(base_path: &str) -> bool {
.any(|component| matches!(component, Component::ParentDir))
}

/// branch명 byte length 상한(방어적 상한, FP<FN).
///
/// loose ref는 파일시스템 경로 컴포넌트(`.git/refs/heads/<branch>`)로 저장되므로 정상 branch명은
/// APFS NAME_MAX(255)를 넘을 수 없어 256B를 절대 초과하지 않는다. 따라서 256B 초과는 정상 git이
/// 만들 수 없는 손상/조작된 `.git/HEAD` 신호이며, 표시를 거부해 false-positive(틀린 branch)보다
/// false-negative(빈 pill)를 택하는 방어적 상한이다(FP<FN).
const MAX_BRANCH_LEN: usize = 256;

/// 주어진 git 작업트리 경로에서 `.git/HEAD`를 읽어 현재 브랜치명을 추출한다.
///
/// # 인자
/// - `base_path`: git 워크트리(또는 repo) 루트 경로.
///
/// # 반환
/// `ref: refs/heads/<branch>` 형식의 HEAD에서 추출한 `<branch>`. detached HEAD(직접 SHA)나
/// 읽기 실패 시 `None`. 부재/실패에 안전(절대 패닉하지 않음).
/// `ref: refs/heads/<branch>` 형식의 HEAD에서 추출한 `<branch>`. 다음 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는 의도적 — `<cwd>/.git/HEAD` 단일 소파일을 1회 read한다. 느린 네트워크 마운트
/// (NFS 등)에서 status 렌더가 블록될 수 있다(알려진 트레이드오프). 완화(timeout/캐시)는 future work.
fn read_branch_from_git_dir(base_path: &str) -> Option<String> {
use std::path::Path;
// 표준 워크트리는 `<base>/.git/HEAD`. (linked worktree의 gitfile 케이스는 v1 범위 밖.)
Expand All @@ -395,7 +411,8 @@ fn read_branch_from_git_dir(base_path: &str) -> Option<String> {
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<FN)
if branch.is_empty() || branch.len() > MAX_BRANCH_LEN || branch.chars().any(char::is_control) {
None
} else {
Some(branch.to_string())
Expand Down Expand Up @@ -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<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}

/// 호출마다 고유한 비존재 절대 temp 경로를 RAII 가드로 감싸 반환한다(테스트 격리).
///
/// # 인자
/// - `label`: 경로를 사람이 식별하기 위한 라벨(테스트 의도 표시).
///
/// # 반환
/// `<temp_dir>/understatus-<label>-<pid>-<seq>` 형태의 고유 절대 경로(미생성).
/// `<temp_dir>/understatus-<label>-<pid>-<seq>` 형태의 고유 절대 경로(미생성)를 담은 [`TestDir`].
/// `Deref<Target=Path>`로 `PathBuf`처럼 `.join()`/`.to_string_lossy()` 등을 그대로 쓸 수 있다.
///
/// # 주의
/// `process::id()` 단독은 같은 프로세스 내 스레드 병렬 실행 시 prefix가 같으면 충돌·stale
/// 누수 위험이 있다. `AtomicU64` 정적 카운터를 조합해 호출마다 고유 경로를 보장한다.
/// 경로는 생성하지 않으므로, `.git` 없는 비존재 경로가 필요한 negative 테스트에도 그대로 쓴다.
fn unique_test_dir(label: &str) -> std::path::PathBuf {
/// 반환된 [`TestDir`]가 drop될 때 디렉터리를 정리하므로 패닉(단언 실패) 시에도 누수가 없다.
fn unique_test_dir(label: &str) -> TestDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("understatus-{label}-{}-{seq}", std::process::id()))
TestDir(
std::env::temp_dir().join(format!("understatus-{label}-{}-{seq}", std::process::id())),
)
}

/// AC-d1: [`TestDir`] 가드가 panic 언와인딩 중에도 디렉터리를 정리함을 증명한다.
/// 클로저 안에서 디렉터리를 생성하고 경로를 외부 변수로 캡처한 뒤 의도적으로 panic을 일으키고,
/// `catch_unwind` 복귀 후 그 경로가 부재함(=Drop이 cleanup 실행)을 단언한다.
#[test]
fn test_dir_guard_cleans_up_on_panic() {
use std::sync::Mutex;
// 패닉 클로저 밖으로 경로를 빼기 위한 캡처 변수(Mutex로 AssertUnwindSafe 충족).
let captured_path: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);

// 의도적 panic을 catch_unwind로 감싼다. 클로저 안에서 가드 생성 + 디렉터리 실생성 후 panic.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let tmp = unique_test_dir("panic-cleanup");
std::fs::create_dir_all(&*tmp).expect("임시 디렉터리 생성 실패");
assert!(tmp.exists(), "panic 전엔 디렉터리가 존재해야 한다");
// 사후 검사를 위해 경로를 클로저 밖으로 캡처한다.
*captured_path.lock().expect("락 획득 실패") = Some(tmp.to_path_buf());
// 의도적 panic: 언와인딩이 시작되며 `tmp`(TestDir)의 Drop이 실행돼야 한다.
panic!("의도적 패닉 — 가드 cleanup 증명용");
}));

// catch_unwind는 panic을 잡아 Err를 반환해야 한다(테스트가 죽지 않음).
assert!(
result.is_err(),
"catch_unwind가 의도적 panic을 포착해야 한다"
);

// 캡처한 경로가 부재함을 단언 → Drop이 언와인딩에서도 cleanup을 실행했음을 증명.
let path = captured_path
.lock()
.expect("락 획득 실패")
.take()
.expect("패닉 전 경로가 캡처돼야 한다");
assert!(
!path.exists(),
"panic 언와인딩 후 가드 Drop이 디렉터리를 정리해 부재해야 한다: {path:?}"
);
}

/// 정상 JSON: 모든 필드가 올바르게 평탄화되어야 한다(AC2).
Expand Down Expand Up @@ -1157,7 +1237,7 @@ mod tests {
fn derives_git_branch_from_head() {
use std::io::Write;
// 임시 워크트리에 .git/HEAD를 만들어 브랜치 파생을 검증한다.
let tmp = std::env::temp_dir().join(format!("understatus-git-test-{}", std::process::id()));
let tmp = unique_test_dir("git-test");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let head = git_dir.join("HEAD");
Expand All @@ -1170,17 +1250,14 @@ mod tests {
);
let input = parse_claude_input(&raw);
assert_eq!(input.git_branch.as_deref(), Some("feature/my-branch"));

let _ = std::fs::remove_dir_all(&tmp);
}

/// repo가 객체(`{host,owner,name}`)로 드리프트해도 git_worktree가 정상이면 폴백 도출이 살아 있다.
/// (repo lenient 흡수가 git_worktree 우선 폴백 체인을 깨지 않음을 직접 고정한다.)
#[test]
fn git_worktree_derives_branch_even_when_repo_is_object() {
use std::io::Write;
let tmp =
std::env::temp_dir().join(format!("understatus-git-repoobj-{}", std::process::id()));
let tmp = unique_test_dir("git-repoobj");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
Expand All @@ -1196,15 +1273,12 @@ mod tests {
Some("main"),
"repo 객체여도 git_worktree로 브랜치 도출"
);

let _ = std::fs::remove_dir_all(&tmp);
}

/// detached HEAD(직접 SHA)는 브랜치명이 없으므로 None이어야 한다.
#[test]
fn detached_head_yields_no_branch() {
let tmp =
std::env::temp_dir().join(format!("understatus-git-detached-{}", std::process::id()));
let tmp = unique_test_dir("git-detached");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
std::fs::write(git_dir.join("HEAD"), "0123456789abcdef\n").expect("HEAD 쓰기 실패");
Expand All @@ -1215,8 +1289,6 @@ mod tests {
);
let input = parse_claude_input(&raw);
assert_eq!(input.git_branch, None);

let _ = std::fs::remove_dir_all(&tmp);
}

/// git_worktree 경로가 존재하지 않으면 브랜치 파생은 None으로 안전 저하한다.
Expand Down Expand Up @@ -1260,8 +1332,6 @@ mod tests {

let branch = derive_git_branch_from_cwd(&tmp.to_string_lossy());
assert_eq!(branch.as_deref(), Some("feature/x"));

let _ = std::fs::remove_dir_all(&tmp);
}

/// detached HEAD(직접 SHA)는 브랜치명이 없으므로 None이어야 한다(AC2).
Expand All @@ -1273,19 +1343,15 @@ mod tests {
std::fs::write(git_dir.join("HEAD"), "0123456789abcdef\n").expect("HEAD 쓰기 실패");

assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);

let _ = std::fs::remove_dir_all(&tmp);
}

/// `.git` 부재 cwd(존재하나 git이 아닌 디렉터리) → None(AC2).
#[test]
fn derive_from_cwd_no_git_dir_none() {
let tmp = unique_test_dir("lterm-git-nogit");
std::fs::create_dir_all(&tmp).expect("임시 디렉터리 생성 실패");
std::fs::create_dir_all(&*tmp).expect("임시 디렉터리 생성 실패");

assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);

let _ = std::fs::remove_dir_all(&tmp);
}

/// traversal cwd(`..` 포함) → is_safe_base_path가 거부해 None이어야 한다(AC2).
Expand All @@ -1310,8 +1376,33 @@ mod tests {

// canonicalize 결과가 `.git/HEAD`로 끝나지 않으므로(outside-ref로 해소) None.
assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);
}

let _ = std::fs::remove_dir_all(&tmp);
/// 상보 테스트: `<cwd>/.git`이 심볼릭 디렉터리이고 그 실제 `.git/HEAD`가 정상 `ref:`이면
/// `Some("<branch>")`를 반환한다(표준 git symlink 추종이 의도된 동작임을 고정 — 동작 변경 0).
/// canonicalize 결과가 여전히 `.git/HEAD`로 끝나므로(실제 디렉터리도 `.git` 명) 누출 가드를 통과한다.
#[test]
#[cfg(unix)]
fn derive_from_cwd_symlink_git_dir_follows_to_some() {
use std::os::unix::fs::symlink;
// 실제 repo: `<real>/.git/HEAD`에 정상 ref를 둔다.
let real = unique_test_dir("lterm-git-symlink-real");
let real_git = real.join(".git");
std::fs::create_dir_all(&real_git).expect("실제 .git 생성 실패");
std::fs::write(real_git.join("HEAD"), "ref: refs/heads/feature/linked\n")
.expect("HEAD 쓰기 실패");

// cwd: `<cwd>/.git`을 실제 `.git` 디렉터리로 심볼릭 링크한다(표준 git symlink 추종 케이스).
let cwd = unique_test_dir("lterm-git-symlink-cwd");
std::fs::create_dir_all(&*cwd).expect("cwd 생성 실패");
symlink(&real_git, cwd.join(".git")).expect("심볼릭 .git 생성 실패");

// canonicalize가 실제 `.git/HEAD`로 해소되고 `.git/HEAD`로 끝나므로 추종이 허용된다.
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()).as_deref(),
Some("feature/linked"),
"심볼릭 .git 추종은 표준 git 동작 — 정상 ref면 branch 도출"
);
}

/// BLOCKER 회귀 가드: 제어문자(ESC/개행)가 섞인 branch명은 거부돼 None이어야 한다.
Expand All @@ -1331,8 +1422,41 @@ mod tests {
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\nINJECT\n")
.expect("HEAD 쓰기 실패");
assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);
}

let _ = std::fs::remove_dir_all(&tmp);
/// 방어적 상한(FP<FN): MAX_BRANCH_LEN(256B) 초과 branch명은 손상/조작 신호로 보고 거부돼
/// None이어야 한다. 경계값(정확히 256B는 허용, 257B는 거부)을 함께 고정한다.
#[test]
fn derive_from_cwd_rejects_overlong_branch() {
let tmp = unique_test_dir("lterm-git-overlong");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");

// 257B branch명(ASCII 1B/문자) → 상한 초과로 거부.
let overlong = "a".repeat(MAX_BRANCH_LEN + 1);
std::fs::write(
git_dir.join("HEAD"),
format!("ref: refs/heads/{overlong}\n"),
)
.expect("HEAD 쓰기 실패");
assert_eq!(
derive_git_branch_from_cwd(&tmp.to_string_lossy()),
None,
"256B 초과 branch명은 거부(FP<FN)"
);

// 정확히 256B branch명 → 허용(경계 미초과). 정상 케이스 회귀 0을 고정한다.
let boundary = "b".repeat(MAX_BRANCH_LEN);
std::fs::write(
git_dir.join("HEAD"),
format!("ref: refs/heads/{boundary}\n"),
)
.expect("HEAD 쓰기 실패");
assert_eq!(
derive_git_branch_from_cwd(&tmp.to_string_lossy()).as_deref(),
Some(boundary.as_str()),
"정확히 256B branch명은 허용(경계)"
);
}

/// 상대경로 cwd는 프로세스 cwd 기준 false-positive를 막기 위해 거부돼 None이어야 한다.
Expand Down Expand Up @@ -1559,7 +1683,5 @@ mod tests {
Some("main"),
"유효 git cwd → branch 도출"
);

let _ = std::fs::remove_dir_all(&tmp);
}
}
32 changes: 32 additions & 0 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,38 @@ mod tests {
);
}

/// 교차-pill 불변식(SF Symbol 계약): cmux는 SF Symbol 이름만 렌더. SF Symbol은 하이픈을
/// 쓰지 않으므로(점·소문자) icon에 `-`가 있으면 Lucide/nerd-font명이라 drop됨. 과거
/// `git-branch` 회귀를 영구 차단하는 계약 테스트. model(sparkles)+git(arrow.triangle.branch)+
/// 미래 pill 전부 커버한다.
#[test]
fn cmux_pill_icons_contain_no_hyphen() {
// enrich-성공 시나리오(model+ctx+git+cpu+mem pill 5종) 재사용.
let mut cfg = Config::default();
cfg.color.mode = "none".to_string();
let out = render_cmux_pills(&sample_input(), &sample_snap(58.0), &cfg, 0, false);
for pill in &out.pills {
if let Some(icon) = pill.icon.as_deref() {
assert!(
!icon.contains('-'),
"pill {} 아이콘에 하이픈 포함(Lucide/nerd-font명 → cmux drop): {icon:?}",
pill.key
);
}
}
// vacuous-pass 방지: sample 시나리오는 pill을 방출하고 최소 model+git 2개 icon이 실제 검사돼야
// 회귀 가드가 의미를 가진다(pill 0개/icon 전부 None이면 위 루프가 조용히 통과하는 것을 차단).
assert!(
!out.pills.is_empty(),
"sample 시나리오는 pill을 방출해야 한다"
);
let icons_checked = out.pills.iter().filter(|p| p.icon.is_some()).count();
assert!(
icons_checked >= 2,
"최소 model(sparkles)+git(arrow.triangle.branch) 2개 icon이 검사돼야 한다(vacuous-pass 방지): {icons_checked}"
);
}

/// AC2(가용 집합): enrich-실패 상당(ctx None, model bare "codex") → pill key 집합 {model,cpu,mem}(3, ctx 부재).
#[test]
fn to_cmux_pills_unenriched_has_three_keys_no_ctx() {
Expand Down