diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1465dfc..c3d552a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,14 @@ jobs: - name: cargo test run: cargo test + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Upgrade npm for packlist parity + run: npm install -g npm@11.5.1 + + - name: npm test + run: npm test --prefix npm diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 51bc2f8..7388d4b 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -22,6 +22,9 @@ on: tag: description: 'Tag to publish (e.g. v0.7.1)' required: true + commit: + description: 'Full commit SHA that refs/tags/ must point to' + required: true permissions: id-token: write # OIDC 토큰 발급에 필수 @@ -33,11 +36,37 @@ jobs: # environment: release # npm 폼에 Environment name을 넣었다면 주석 해제(동일 값) env: TAG: ${{ github.event.inputs.tag || github.ref_name }} + EXPECTED_COMMIT: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.commit || github.sha }} steps: - name: Checkout (tag) uses: actions/checkout@v6 with: - ref: ${{ github.event.inputs.tag || github.ref_name }} + ref: refs/tags/${{ github.event.inputs.tag || github.ref_name }} + fetch-depth: 0 + + - name: Verify checked out release tag + run: | + set -euo pipefail + if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then + echo "Invalid release tag: $TAG" >&2 + exit 1 + fi + if ! printf '%s' "$EXPECTED_COMMIT" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "Invalid expected commit SHA: $EXPECTED_COMMIT" >&2 + exit 1 + fi + git fetch --force --tags origin "refs/tags/$TAG:refs/tags/$TAG" + head_commit="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + tag_commit="$(git rev-parse "refs/tags/$TAG^{commit}" | tr '[:upper:]' '[:lower:]')" + expected_commit="$(printf '%s' "$EXPECTED_COMMIT" | tr '[:upper:]' '[:lower:]')" + if [ "$head_commit" != "$tag_commit" ]; then + echo "Checked out $head_commit but refs/tags/$TAG points to $tag_commit" >&2 + exit 1 + fi + if [ "$tag_commit" != "$expected_commit" ]; then + echo "refs/tags/$TAG points to $tag_commit but expected $expected_commit" >&2 + exit 1 + fi - name: Setup Node + npm registry uses: actions/setup-node@v4 @@ -46,8 +75,12 @@ jobs: registry-url: 'https://registry.npmjs.org' # Trusted Publishing(OIDC)은 npm CLI >= 11.5.1을 요구한다. Node 22 기본 npm은 10.x라 업그레이드. + # Keep packlist verification and publish on the same npm version. - name: Upgrade npm (OIDC 요구 버전) - run: npm install -g npm@latest + run: npm install -g npm@11.5.1 + + - name: Verify release source contract + run: node npm/verify-release.js "${TAG#v}" --verify-packlist # 릴리스 바이너리(arm64/x64 tarball)가 올라올 때까지 대기(최대 ~10분). # release.yml과 같은 태그 push에 동시 트리거되므로, 보통 수십 초 내 충족된다. @@ -60,7 +93,11 @@ jobs: for i in $(seq 1 60); do names="$(gh release view "$TAG" --json assets -q '.assets[].name' 2>/dev/null || true)" if printf '%s\n' "$names" | grep -qx "understatus-${VER}-aarch64-apple-darwin.tar.gz" \ - && printf '%s\n' "$names" | grep -qx "understatus-${VER}-x86_64-apple-darwin.tar.gz"; then + && printf '%s\n' "$names" | grep -qx "understatus-${VER}-aarch64-apple-darwin.tar.gz.sha256" \ + && printf '%s\n' "$names" | grep -qx "understatus-${VER}-aarch64-apple-darwin.tar.gz.provenance.json" \ + && printf '%s\n' "$names" | grep -qx "understatus-${VER}-x86_64-apple-darwin.tar.gz" \ + && printf '%s\n' "$names" | grep -qx "understatus-${VER}-x86_64-apple-darwin.tar.gz.sha256" \ + && printf '%s\n' "$names" | grep -qx "understatus-${VER}-x86_64-apple-darwin.tar.gz.provenance.json"; then echo "릴리스 에셋 확인됨 ($TAG)" exit 0 fi @@ -70,6 +107,43 @@ jobs: echo "릴리스 에셋을 시간 내 찾지 못함: $TAG" >&2 exit 1 + # checksums.json is generated from the exact release assets for the npm tarball + # being published. A follow-up read-only verification keeps generation and + # manifest verification separate so --require-checksums cannot be neutralized. + - name: Verify release binary checksums + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + VER="${TAG#v}" + ASSET_DIR="${RUNNER_TEMP}/understatus-assets" + rm -rf "$ASSET_DIR" + mkdir -p "$ASSET_DIR" + for target in aarch64-apple-darwin x86_64-apple-darwin; do + gh release download "$TAG" \ + --dir "$ASSET_DIR" \ + --pattern "understatus-${VER}-${target}.tar.gz" + gh release download "$TAG" \ + --dir "$ASSET_DIR" \ + --pattern "understatus-${VER}-${target}.tar.gz.sha256" + gh release download "$TAG" \ + --dir "$ASSET_DIR" \ + --pattern "understatus-${VER}-${target}.tar.gz.provenance.json" + done + node npm/verify-release.js "$VER" \ + --tarball-dir "$ASSET_DIR" \ + --verify-sidecars \ + --verify-provenance \ + --expected-commit "$EXPECTED_COMMIT" \ + --write-checksums \ + --verify-packlist + node npm/verify-release.js "$VER" \ + --tarball-dir "$ASSET_DIR" \ + --verify-provenance \ + --expected-commit "$EXPECTED_COMMIT" \ + --require-checksums \ + --verify-packlist + # 래퍼 서브디렉터리(./npm)를 publish. Trusted Publishing 사용 시 NODE_AUTH_TOKEN 불필요, # provenance는 자동 생성된다. understatus는 unscoped 패키지라 기본 public. - name: npm publish (./npm) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9db0412..9ebe5c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,11 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to build and upload (e.g. v0.1.0)' + description: 'Tag to build and upload (e.g. v0.7.0)' + required: true + commit: + description: 'Full commit SHA that refs/tags/ must point to' required: true - default: 'v0.1.0' permissions: contents: write @@ -28,11 +30,48 @@ jobs: - x86_64-apple-darwin env: TAG: ${{ github.event.inputs.tag || github.ref_name }} + EXPECTED_COMMIT: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.commit || github.sha }} steps: - name: Checkout (tag) uses: actions/checkout@v6 with: - ref: ${{ github.event.inputs.tag || github.ref_name }} + ref: refs/tags/${{ github.event.inputs.tag || github.ref_name }} + fetch-depth: 0 + + - name: Verify checked out release tag + run: | + set -euo pipefail + if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then + echo "Invalid release tag: $TAG" >&2 + exit 1 + fi + if ! printf '%s' "$EXPECTED_COMMIT" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "Invalid expected commit SHA: $EXPECTED_COMMIT" >&2 + exit 1 + fi + git fetch --force --tags origin "refs/tags/$TAG:refs/tags/$TAG" + head_commit="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + tag_commit="$(git rev-parse "refs/tags/$TAG^{commit}" | tr '[:upper:]' '[:lower:]')" + expected_commit="$(printf '%s' "$EXPECTED_COMMIT" | tr '[:upper:]' '[:lower:]')" + if [ "$head_commit" != "$tag_commit" ]; then + echo "Checked out $head_commit but refs/tags/$TAG points to $tag_commit" >&2 + exit 1 + fi + if [ "$tag_commit" != "$expected_commit" ]; then + echo "refs/tags/$TAG points to $tag_commit but expected $expected_commit" >&2 + exit 1 + fi + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Upgrade npm for packlist parity + run: npm install -g npm@11.5.1 + + - name: Verify release source contract + run: node npm/verify-release.js "${TAG#v}" --verify-packlist - name: Rust toolchain (+ target) uses: dtolnay/rust-toolchain@stable @@ -48,6 +87,23 @@ jobs: cd target/${{ matrix.target }}/release tar czf understatus-${VERSION}-${{ matrix.target }}.tar.gz understatus shasum -a 256 understatus-${VERSION}-${{ matrix.target }}.tar.gz > understatus-${VERSION}-${{ matrix.target }}.tar.gz.sha256 + SHA256="$(cut -d ' ' -f 1 "understatus-${VERSION}-${{ matrix.target }}.tar.gz.sha256")" + COMMIT="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + cat > understatus-${VERSION}-${{ matrix.target }}.tar.gz.provenance.json < **macOS only.** Apple Silicon (arm64) + Intel (x86\_64). Linux is not supported. > -> **No Xcode required for the prebuilt paths.** Homebrew and npm install a **prebuilt binary** (deployment target macOS 11.0), so they need **no Xcode Command Line Tools and no Rust toolchain**, and run on **macOS 11 (Big Sur) and newer**. Only `cargo install` / building from source compiles locally and therefore needs the Rust toolchain + Command Line Tools. +> **No Xcode required for the prebuilt paths.** Homebrew and npm install **prebuilt binaries** (deployment target macOS 11.0), so they need **no Xcode Command Line Tools and no Rust toolchain**, and run on **macOS 11 (Big Sur) and newer**. Only `cargo install` / building from source compiles locally and therefore needs the Rust toolchain + Command Line Tools. --- @@ -217,7 +217,7 @@ All keys are optional; omitting a key uses its default. | Key | Default | Description | |-----|---------|-------------| | `theme` | `"calm"` | Active theme preset. Valid values: `calm`, `mono`, `vivid`, `ember`, `emoji`, `neon`, `aurora`, `sunset`, `spectrum`. The theme fills all eight visual keys not explicitly set in config; individual keys can still override it. | -| `[cpu] sample_window_ms` | `25` | Interval (ms) between the two CPU snapshots. Larger = less noise, more latency. | +| `[cpu] sample_window_ms` | `25` | Interval (ms) between the two CPU snapshots. Larger = less noise, more latency; the renderer caps this at 100 ms to keep the statusline hot path bounded. | | `[cpu] load_glyphs` | `["○","▁","▄","▆","◆"]` | Glyphs for idle→critical load stages. Color is applied to the glyph only. Filled by the active theme; override by writing this key explicitly. | | `[pulse] pulse_on_threshold` | `90` | CPU% at which the critical glyph starts breathing. | | `[pulse] pulse_off_threshold` | `80` | CPU% below which the breath turns off (hysteresis). | @@ -231,10 +231,13 @@ All keys are optional; omitting a key uses its default. | `[display] show_model` | `true` | Show Claude model name. | | `[display] show_cost` | `true` | Show cumulative session cost. | | `[display] show_context` | `true` | Show context usage %. Omitted automatically when null. | -| `[display] show_git` | `true` | Show git branch (derived from `workspace.git_worktree` / repo). | +| `[display] show_git` | `true` | Show git branch (Claude workspace path, or lterm/codex `cwd` walk-up when available). | +| `[display] show_session` | `true` | Show lterm/codex session/pane label such as `codex/%3` when supplied by the input payload. | | `[display] show_battery` | `true` | Show battery (IOKit, 30 s TTL cache). Silently omitted on desktops. | | `[display] show_disk` | `true` | Show disk usage via `statfs("/")`. | | `[display] show_network` | `true` | Show network throughput (getifaddrs counter delta). First render has no delta — omitted silently. | +| `[display] show_rate_limit` | `true` | Show Claude Code 5h/weekly rate-limit usage when `rate_limits` is present in stdin. Codex 5h/weekly usage is shown from Codex session enrichment when available. | +| `[display] rate_limit_warn_threshold` | not set | Optional percent threshold. When set, Claude rate-limit values at or above the threshold are tinted with the critical color. | | `[color] mode` | `"auto"` | `"auto"` \| `"truecolor"` \| `"256"` \| `"none"`. Respects `NO_COLOR`. | | `[color] band_tints` | see below | Five hex colors for idle→critical glyph tint. Filled by the active theme; override by writing this key explicitly. | | `[color] pulse_palette` | `["#b87848","#7a5030"]` | High/low brightness endpoints for the breath animation. Filled by the active theme; override by writing this key explicitly. | @@ -243,6 +246,9 @@ All keys are optional; omitting a key uses its default. | `[color] separator_color` | `"#3b4048"` | Color for separator and HUD seam. | | `[color] hud_seam` | `"│"` | Character placed between understatus output and the chained command output. | | `[refresh] interval_seconds` | `5` | Value written to `settings.json` as `refreshInterval`. Set via `install --interval` or the interactive prompt. On reinstall the existing value is inherited unless `--interval` overrides it. ⚠️ Global side-effect — see above. | +| `[codex] enabled` | `true` | Enable best-effort Codex session enrichment for `--source lterm` / `--source codex` payloads whose agent looks like Codex. | +| `[codex] freshness_minutes` | `240` | Only consider Codex rollout files modified within this many minutes. | +| `[codex] scan_days` | `3` | Scan only this many recent Codex session date directories to keep `~/.codex` reads bounded. | | `[context] hold_ttl_seconds` | `180` | How long (s) to hold the previous native context % when Claude Code omits `used_percentage`. Lower = faster tracking of the real value; too low and ctx can briefly blank out when both native and token counts are 0. `0` disables hold. | | `[context] drop_tolerance` | `12` | Downward threshold (percentage points) for breaking the hold and switching to the token fallback. The fallback must be at least this far below the held native to count as a real drop (e.g. `/compact`). ⚠️ Lowering below 12 can reintroduce flicker from the native↔token denominator gap (observed 86↔98 = 12 pp). | @@ -261,7 +267,9 @@ Claude Code (every refreshInterval seconds) │ stdin: one JSON line ▼ understatus binary (new process per call — no daemon, no state files, no locks) - ├─ parse stdin → ClaudeInput (session_id extracted here) + ├─ read bounded stdin (1 MiB) → parse into ClaudeInput/lterm payload + ├─ load bounded config (256 KiB; invalid/oversized = defaults) + ├─ optional Codex enrich (`--source lterm|codex`, bounded ~/.codex scan) ├─ double-sample CPU → cpu_percent (0–100%, average across all cores) │ on failure → loadavg fallback: min(load1 / ncpu × 100, 100) ├─ memory (host_statistics64) @@ -274,7 +282,9 @@ understatus binary (new process per call — no daemon, no state files, no lock └─ compose → stdout (single newline) ``` -**CPU measurement:** Two `/proc`-equivalent snapshots are taken ~25 ms apart within the same process invocation. The delta gives true instantaneous utilization — not a smoothed load average. If the syscall fails (rare), `loadavg` serves as a silent fallback. +**CPU measurement:** Two macOS `host_processor_info` snapshots are taken ~25 ms apart within the same process invocation. The delta gives true instantaneous utilization — not a smoothed load average. If the syscall fails (rare), `loadavg` serves as a silent fallback. User-configured sample windows are capped at 100 ms so a statusline render cannot be delayed indefinitely by config. + +**Claude rate limits:** When Claude Code includes `rate_limits` in its statusLine stdin payload, understatus renders the 5h/weekly usage percent and reset countdown without making network calls. The segment is gated by `[display].show_rate_limit` and can be optionally tinted via `[display].rate_limit_warn_threshold`. **Glyph + tint design (COLOR-ONCE):** `band_tints[0..3]` are cool blue-grey values of increasing brightness (idle to high load). `band_tints[4]` is the lone warm color — terracotta — reserved for the critical stage. Only the glyph character receives color; all numeric values and labels stay uncolored. The active theme fills these colors; individual config keys override the preset. @@ -288,11 +298,23 @@ understatus binary (new process per call — no daemon, no state files, no lock | CLI | Status | Notes | |-----|--------|-------| -| **Claude Code** | ✅ Full support | Custom `statusLine.command`, stdin JSON, `refreshInterval` (default 5 s) — all supported. | -| **Gemini CLI** | ⏳ Stub / forward-looking | `/footer` and `/statusline` expose built-in items only; custom commands not yet supported (open issue). | -| **Codex CLI** | ⏳ Stub / forward-looking | `[tui].status_line` is a fixed built-in; custom commands not yet supported (open issue). | +| **Claude Code** | ✅ Full support | Custom `statusLine.command`, stdin JSON, `refreshInterval`, chain preservation, and Claude `rate_limits` rendering are supported. | +| **lterm / cmux** | ✅ Native pill surface | `understatus render --source lterm --surface-format cmux-status` emits one-line JSON for cmux status pills. The plain lterm path can also use `--source lterm --oneline`. | +| **Codex CLI (standalone)** | ⚠️ Manual/tmux path | Codex has a built-in `[tui].status_line` item list, not a Claude-style command hook. For tmux or shell status integrations, feed a small JSON payload to `understatus render --source codex --oneline`; understatus then enriches from `~/.codex` on a best-effort basis. | +| **Gemini CLI** | ⏳ Forward-looking | `/footer` and `/statusline` expose built-in items only; custom command-backed statuslines are not supported by understatus today. | + +Examples: + +```bash +# Codex tmux-style one-line text +printf '{"agent":"codex","cwd":"%s"}' "$PWD" | understatus render --source codex --oneline + +# lterm cmux native status pills (JSON, no ANSI) +printf '{"source":"lterm","session":"codex","pane":"%%3","cwd":"%s","agent":"codex"}' "$PWD" \ + | understatus render --source lterm --surface-format cmux-status +``` -Gemini and Codex integration is documented and stubbed (`CliAdapter` trait planned) but not yet functional — those CLIs do not currently expose a custom statusline command hook. +For tmux status bars, prefer `color.mode = "none"` and let tmux apply its own `#[fg=...]` styling if raw ANSI SGR is not desired. --- @@ -300,7 +322,7 @@ Gemini and Codex integration is documented and stubbed (`CliAdapter` trait plann - **macOS only.** Uses `host_processor_info`, `host_statistics64`, and `IOPSCopyPowerSourcesInfo` — all macOS-specific APIs. - **Apple Silicon (arm64) + Intel (x86\_64).** Tested on macOS arm64 with a 12-core Apple Silicon chip. -- Linux: builds may succeed, but CPU double-sampling degrades silently to loadavg fallback. Not a supported target. +- Linux: builds may succeed, but understatus is not a supported Linux target; macOS-specific metrics may degrade or be omitted. - **Rust edition 2021, MSRV 1.75+.** --- @@ -313,16 +335,18 @@ MIT — see [LICENSE](LICENSE). ## 한국어 안내 -macOS용 AI 코딩 CLI statusline 애드온입니다. CPU%, 메모리, 배터리, 디스크, 네트워크, AI 세션 정보(모델명·비용·컨텍스트)를 Claude Code 하단 표시줄에 조용하고 자연스럽게 표시합니다. +macOS용 AI 코딩 CLI statusline 애드온입니다. CPU%, 메모리, 배터리, 디스크, 네트워크, AI 세션 정보(모델명·비용·컨텍스트·rate-limit)를 Claude Code 하단 표시줄에 조용하고 자연스럽게 표시합니다. **주요 특징** - **9종 테마** — `calm`(기본), `mono`, `vivid`, `ember`, `emoji`, `neon`, `aurora`, `sunset`, `spectrum`. 테마는 8개 시각 키(글리프·색상 등)를 한 번에 설정하며, 개별 키를 config.toml에 명시하면 테마보다 우선합니다. - **COLOR-ONCE 원칙** — 색은 글리프 문자에만 적용. 숫자 값(CPU%, 비용 등)은 항상 무색. - **≥90% 호흡** — CPU가 90% 이상으로 유지되면 임계 밴드 글리프가 테라코타 명도로 천천히 숨쉽니다(hue 변화 없음). 부드러운 애니메이션에는 `pulse_period / interval_seconds >= 6` 조건이 필요하며, 위반 시 설치 시점에 경고가 출력됩니다. -- **반응형 CPU** — 매 렌더마다 두 스냅샷(~25ms 간격) 직접 측정. loadavg 아님. -- **비파괴 설치** — 기존 `statusLine.command`를 체이닝으로 보존하고 정확히 복원. +- **반응형 CPU** — 매 렌더마다 두 스냅샷(~25ms 간격)을 직접 측정합니다. loadavg가 아닙니다. +- **비파괴 설치** — 기존 `statusLine.command`를 체이닝으로 보존하고 정확히 복원합니다. - **세션 캐시 격리** — 체인 출력·펄스 상태·네트워크 델타 캐시는 `session_id`별로 분리되어 여러 터미널을 동시에 열어도 값이 섞이지 않습니다. 배터리는 머신 전역. +- **Claude rate-limit 표시** — Claude Code stdin에 `rate_limits`가 들어오면 5h/weekly 사용률과 리셋 카운트다운을 네트워크 호출 없이 표시합니다. +- **lterm/cmux·Codex 경로** — `--source lterm --surface-format cmux-status`는 cmux pill JSON을 출력하고, `--source codex --oneline`은 tmux나 셸 status 연동 환경에서 `~/.codex`를 best-effort로 보강합니다. **설치** @@ -344,6 +368,12 @@ understatus install [--interval N] [--theme NAME] [--yes] understatus uninstall # 원상 복원 ``` +Codex를 tmux status에서 직접 쓰는 경우: + +```bash +printf '{"agent":"codex","cwd":"%s"}' "$PWD" | understatus render --source codex --oneline +``` + `--interval`/`--theme` 미지정 + TTY 환경이면 각 항목을 대화형으로 묻습니다. `--yes`(또는 비TTY)이면 플래그·기존값·기본값을 그대로 사용합니다. 재설치 시 `--interval`을 지정하지 않으면 기존 `config.toml`의 interval이 그대로 승계됩니다(기본 5초로 초기화되지 않습니다). > ⚠️ `install`은 `settings.json`에 `"refreshInterval": N`을 전역 주입합니다. 체이닝된 기존 명령도 N초마다 재실행 대상이 됩니다. 배터리 절약이 필요하면 `config.toml`에서 `interval_seconds = 10`으로 올리세요. @@ -381,7 +411,7 @@ CPU가 `pulse_on_threshold`(기본 90%) 이상이면 임계 밴드 글리프가 화려한 테마 기본: `neon`·`spectrum` = `hue`, `aurora`·`sunset` = `flash`. 기존 5종은 모두 `calm`. 개별 키로 재정의 가능. -펄스가 OFF(CPU < `pulse_off_threshold`)이면 스타일과 무관하게 정적 밴드 틴트로 표시됩니다 (애니메이션 없음). +펄스가 OFF(CPU < `pulse_off_threshold`)이면 스타일과 무관하게 정적 밴드 틴트로 표시됩니다(애니메이션 없음). ```toml # ~/.config/understatus/config.toml diff --git a/docs/design-lab.html b/docs/design-lab.html index 3dae0c2..58956d6 100644 --- a/docs/design-lab.html +++ b/docs/design-lab.html @@ -3,7 +3,7 @@ -understatus — Calm-by-Default Direction +understatus — Calm Statusline Design Lab
-
Light terminal · State 4 · CPU ≥90% · restrained warm pulse
+
Light terminal · State 4 · CPU ≥90% · restrained terracotta pulse
@@ -823,7 +817,7 @@ · - Claude Opus 4.8 + Claude Opus · @@ -844,7 +838,7 @@ $14.02 - [OMC#4.14.2] | Model: Opus 4.8 | 5h:5% | session:361m | ctx:88% | tokens:491k | cost:$14.02 + [chain] | Model: Opus | 5h:5% | session:361m | ctx:88% | tokens:491k | cost:$14.02
diff --git a/npm/checksums.json b/npm/checksums.json new file mode 100644 index 0000000..aac459f --- /dev/null +++ b/npm/checksums.json @@ -0,0 +1,6 @@ +{ + "0.7.0": { + "aarch64-apple-darwin": "2ed437c931caec6b5d150a866c3e5b9a4dcfb3df3a6af1288b9280c5a3baf757", + "x86_64-apple-darwin": "1de3816c372ab8f3a69dc55e9a624bbf9c52a76d95938913b84c2866b72771a0" + } +} diff --git a/npm/install.js b/npm/install.js index 1f45af1..460c7ed 100644 --- a/npm/install.js +++ b/npm/install.js @@ -17,50 +17,28 @@ const fs = require('fs'); const crypto = require('crypto'); const { spawnSync } = require('child_process'); const path = require('path'); +const os = require('os'); // 네이티브 바이너리를 받아오는 GitHub 릴리스 태그. -// npm 래퍼 패키지 버전(package.json)과는 분리되어 있습니다 — -// 래퍼(이 스크립트/런처)만 패치 게시될 수 있으므로, 바이너리는 항상 -// 아래 고정된 릴리스에서 받습니다. 새 바이너리 릴리스를 낼 때 이 값을 올리세요. +// release/publish guard가 Cargo.toml, npm/package.json, 이 값, git tag를 lockstep으로 +// 검증합니다. 새 네이티브 릴리스를 낼 때 네 버전을 함께 올리세요. const VERSION = '0.7.0'; -// 플랫폼 확인: macOS 전용 패키지 -if (process.platform !== 'darwin') { - process.stdout.write( - '[understatus] macOS 전용 패키지입니다. 현재 플랫폼(' + - process.platform + - ')에서는 설치를 건너뜁니다.\n' - ); - process.exit(0); -} - // CPU 아키텍처를 Rust 타겟 트리플로 매핑 const archMap = { arm64: 'aarch64-apple-darwin', x64: 'x86_64-apple-darwin', }; -const target = archMap[process.arch]; -if (!target) { - process.stderr.write( - '[understatus] 지원하지 않는 아키텍처입니다: ' + process.arch + '\n' + - '[understatus] 지원 아키텍처: arm64 (Apple Silicon), x64 (Intel)\n' - ); - process.exit(1); -} - // 다운로드 URL 구성 const RELEASE_BASE = 'https://github.com/ictechgy/understatus/releases/download/v' + VERSION + '/'; -const TARBALL_NAME = 'understatus-' + VERSION + '-' + target + '.tar.gz'; -const SHA256_NAME = TARBALL_NAME + '.sha256'; -const TARBALL_URL = RELEASE_BASE + TARBALL_NAME; -const SHA256_URL = RELEASE_BASE + SHA256_NAME; // bin 디렉터리 경로 const BIN_DIR = path.join(__dirname, 'bin'); -const TARBALL_PATH = path.join(BIN_DIR, TARBALL_NAME); const BINARY_PATH = path.join(BIN_DIR, 'understatus'); +const TAR = '/usr/bin/tar'; +const DOWNLOAD_TIMEOUT_MS = 30000; /** * HTTP(S) GET 요청으로 URL에서 데이터를 다운로드합니다. @@ -77,68 +55,152 @@ function download(url, destPath, maxRedirects = 10) { return reject(new Error('너무 많은 리디렉션이 발생했습니다: ' + url)); } - https - .get(url, (response) => { - // 리디렉션 처리 - if ( - [301, 302, 307, 308].includes(response.statusCode) && - response.headers.location - ) { - response.resume(); // 현재 응답 본문 소비 - return resolve( - download(response.headers.location, destPath, maxRedirects - 1) - ); - } + function rejectWithCleanup(err) { + if (destPath) { + fs.unlink(destPath, () => {}); + } + reject(err); + } - if (response.statusCode !== 200) { - response.resume(); - return reject( - new Error( - 'HTTP ' + response.statusCode + ' 오류: ' + url + '\n' + - '릴리즈가 존재하는지 확인하세요: ' + - 'https://github.com/ictechgy/understatus/releases' - ) - ); + const request = https.get(url, (response) => { + // 리디렉션 처리 + if ( + [301, 302, 307, 308].includes(response.statusCode) && + response.headers.location + ) { + response.resume(); // 현재 응답 본문 소비 + let nextUrl; + try { + nextUrl = new URL(response.headers.location, url); + } catch (err) { + return rejectWithCleanup(new Error('잘못된 리디렉션 URL: ' + response.headers.location)); } - - if (destPath) { - // 파일로 저장 - const fileStream = fs.createWriteStream(destPath); - response.pipe(fileStream); - fileStream.on('finish', () => fileStream.close(resolve)); - fileStream.on('error', (err) => { - fs.unlink(destPath, () => {}); // 실패 시 임시 파일 삭제 - reject(err); - }); - response.on('error', reject); - } else { - // Buffer로 수집 - const chunks = []; - response.on('data', (chunk) => chunks.push(chunk)); - response.on('end', () => resolve(Buffer.concat(chunks))); - response.on('error', reject); + if (nextUrl.protocol !== 'https:') { + return rejectWithCleanup(new Error('HTTPS가 아닌 리디렉션 거부: ' + nextUrl.toString())); } - }) - .on('error', reject); + return resolve(download(nextUrl.toString(), destPath, maxRedirects - 1)); + } + + if (response.statusCode !== 200) { + response.resume(); + return rejectWithCleanup( + new Error( + 'HTTP ' + response.statusCode + ' 오류: ' + url + '\n' + + '릴리즈가 존재하는지 확인하세요: ' + + 'https://github.com/ictechgy/understatus/releases' + ) + ); + } + + if (destPath) { + // 파일로 저장 + const fileStream = fs.createWriteStream(destPath); + response.pipe(fileStream); + fileStream.on('finish', () => fileStream.close(resolve)); + fileStream.on('error', rejectWithCleanup); + response.on('error', rejectWithCleanup); + } else { + // Buffer로 수집 + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(Buffer.concat(chunks))); + response.on('error', rejectWithCleanup); + } + }); + + request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => { + request.destroy(new Error('다운로드 시간 초과: ' + url)); + }); + request.on('error', rejectWithCleanup); }); } /** - * SHA-256 체크섬 파일을 파싱하여 기대 해시값을 반환합니다. - * 형식: " " + * npm 패키지에 고정된 체크섬 manifest에서 기대 해시값을 반환합니다. * - * @param {string} sha256Content - 체크섬 파일의 텍스트 내용 + * @param {string} version - 네이티브 바이너리 버전 + * @param {string} releaseTarget - Rust 타겟 트리플 * @returns {string} 소문자 16진수 SHA-256 해시 */ -function parseSha256(sha256Content) { - const line = sha256Content.trim().split('\n')[0]; - const parts = line.split(/\s+/); - if (!parts[0] || parts[0].length !== 64) { +function removeDirQuietly(dirPath) { + if (!dirPath) { + return; + } + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + } catch (_) { + // cleanup best effort + } +} + +function expectedChecksum(version, releaseTarget) { + const checksums = require('./checksums.json'); + const releaseChecksums = checksums[version]; + const checksum = releaseChecksums && releaseChecksums[releaseTarget]; + if (!checksum || !/^[0-9a-f]{64}$/.test(checksum)) { + throw new Error( + 'npm/checksums.json에 ' + version + '/' + releaseTarget + ' 체크섬이 없습니다.' + ); + } + return checksum; +} + +/** + * tarball 항목을 검증하고 temp dir에 안전하게 압축 해제한 뒤 바이너리 경로를 반환합니다. + * + * @param {string} tarballPath - 검증할 tarball 경로 + * @returns {string} temp dir 안에 압축 해제된 understatus 바이너리 경로 + */ +function extractValidatedBinary(tarballPath) { + const listResult = spawnSync(TAR, ['-tzf', tarballPath], { + encoding: 'utf8', + }); + if (listResult.error) { + throw new Error('tar 목록 조회 오류: ' + listResult.error.message); + } + if (listResult.status !== 0) { + throw new Error('tar 목록 조회 실패 (종료 코드 ' + listResult.status + '): ' + listResult.stderr); + } + + const entries = listResult.stdout + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + if (entries.length !== 1 || entries[0] !== 'understatus') { + throw new Error( + '예상하지 않은 tarball 항목: ' + (entries.length ? entries.join(', ') : '(비어 있음)') + ); + } + + const extractDir = fs.mkdtempSync(path.join(BIN_DIR, '.understatus-extract-')); + const extractResult = spawnSync(TAR, ['-xzf', tarballPath, '-C', extractDir, 'understatus'], { + stdio: 'pipe', + encoding: 'utf8', + }); + if (extractResult.error) { + removeDirQuietly(extractDir); + throw new Error('tar 압축 해제 오류: ' + extractResult.error.message); + } + if (extractResult.status !== 0) { + removeDirQuietly(extractDir); throw new Error( - 'SHA-256 파일 형식을 파싱할 수 없습니다. 내용: ' + sha256Content + 'tar 압축 해제 실패 (종료 코드 ' + extractResult.status + '): ' + extractResult.stderr ); } - return parts[0].toLowerCase(); + + const extractedBinary = path.join(extractDir, 'understatus'); + let stat; + try { + stat = fs.lstatSync(extractedBinary); + } catch (err) { + removeDirQuietly(extractDir); + throw new Error('압축 해제된 understatus를 찾을 수 없습니다: ' + err.message); + } + if (!stat.isFile()) { + removeDirQuietly(extractDir); + throw new Error('압축 해제된 understatus가 regular file이 아닙니다.'); + } + return extractedBinary; } /** @@ -159,9 +221,31 @@ function computeSha256(filePath) { /** * 메인 설치 함수 - * 순서: SHA256 다운로드 → tarball 다운로드 → 체크섬 검증 → 압축 해제 → chmod + * 순서: 고정 checksum 조회 → tarball 다운로드 → 체크섬 검증 → 안전 압축 해제 → chmod */ async function main() { + // 플랫폼 확인: macOS 전용 패키지 + if (process.platform !== 'darwin') { + process.stdout.write( + '[understatus] macOS 전용 패키지입니다. 현재 플랫폼(' + + process.platform + + ')에서는 설치를 건너뜁니다.\n' + ); + return; + } + + const target = archMap[process.arch]; + if (!target) { + process.stderr.write( + '[understatus] 지원하지 않는 아키텍처입니다: ' + process.arch + '\n' + + '[understatus] 지원 아키텍처: arm64 (Apple Silicon), x64 (Intel)\n' + ); + process.exit(1); + } + + const tarballName = 'understatus-' + VERSION + '-' + target + '.tar.gz'; + const tarballUrl = RELEASE_BASE + tarballName; + // bin 디렉터리가 없으면 생성 if (!fs.existsSync(BIN_DIR)) { fs.mkdirSync(BIN_DIR, { recursive: true }); @@ -169,24 +253,24 @@ async function main() { process.stdout.write('[understatus] 설치 중... (버전 ' + VERSION + ', 타겟 ' + target + ')\n'); - // 1단계: SHA-256 체크섬 파일 다운로드 - process.stdout.write('[understatus] SHA-256 체크섬 다운로드 중: ' + SHA256_URL + '\n'); - let sha256Buffer; + // 1단계: npm 패키지에 고정된 SHA-256 체크섬 조회 + let expectedHash; try { - sha256Buffer = await download(SHA256_URL, null); + expectedHash = expectedChecksum(VERSION, target); } catch (err) { - process.stderr.write( - '[understatus] SHA-256 파일 다운로드 실패:\n ' + err.message + '\n' - ); + process.stderr.write('[understatus] 체크섬 manifest 오류: ' + err.message + '\n'); process.exit(1); } - const expectedHash = parseSha256(sha256Buffer.toString('utf8')); + + const downloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'understatus-download-')); + const tarballPath = path.join(downloadDir, tarballName); // 2단계: tarball 다운로드 - process.stdout.write('[understatus] 바이너리 다운로드 중: ' + TARBALL_URL + '\n'); + process.stdout.write('[understatus] 바이너리 다운로드 중: ' + tarballUrl + '\n'); try { - await download(TARBALL_URL, TARBALL_PATH); + await download(tarballUrl, tarballPath); } catch (err) { + removeDirQuietly(downloadDir); process.stderr.write( '[understatus] tarball 다운로드 실패:\n ' + err.message + '\n' ); @@ -197,15 +281,15 @@ async function main() { process.stdout.write('[understatus] 체크섬 검증 중...\n'); let actualHash; try { - actualHash = await computeSha256(TARBALL_PATH); + actualHash = await computeSha256(tarballPath); } catch (err) { - fs.unlink(TARBALL_PATH, () => {}); + removeDirQuietly(downloadDir); process.stderr.write('[understatus] 체크섬 계산 실패: ' + err.message + '\n'); process.exit(1); } if (actualHash !== expectedHash) { - fs.unlink(TARBALL_PATH, () => {}); + removeDirQuietly(downloadDir); process.stderr.write( '[understatus] SHA-256 체크섬 불일치! 다운로드가 손상되었을 수 있습니다.\n' + ' 기대값: ' + expectedHash + '\n' + @@ -217,40 +301,29 @@ async function main() { } process.stdout.write('[understatus] 체크섬 검증 통과.\n'); - // 4단계: tarball 압축 해제 (시스템 tar 사용) - // tarball 루트에 "understatus" 실행 파일 하나만 포함되어 있습니다. + // 4단계: tarball 항목 검증 후 temp dir에 안전 압축 해제 process.stdout.write('[understatus] 압축 해제 중...\n'); - const tarResult = spawnSync('tar', ['-xzf', TARBALL_PATH, '-C', BIN_DIR], { - stdio: 'inherit', - }); - - // tarball 임시 파일 삭제 + let extractedBinary; try { - fs.unlinkSync(TARBALL_PATH); - } catch (_) { - // 삭제 실패는 무시 - } - - if (tarResult.error) { - process.stderr.write( - '[understatus] tar 실행 오류: ' + tarResult.error.message + '\n' - ); - process.exit(1); - } - if (tarResult.status !== 0) { - process.stderr.write( - '[understatus] tar 압축 해제 실패 (종료 코드 ' + tarResult.status + ').\n' - ); + extractedBinary = extractValidatedBinary(tarballPath); + fs.renameSync(extractedBinary, BINARY_PATH); + removeDirQuietly(path.dirname(extractedBinary)); + } catch (err) { + if (extractedBinary) { + removeDirQuietly(path.dirname(extractedBinary)); + } + process.stderr.write('[understatus] 압축 해제 실패: ' + err.message + '\n'); process.exit(1); + } finally { + // tarball 임시 파일 삭제 + removeDirQuietly(downloadDir); } // 5단계: 실행 권한 부여 try { fs.chmodSync(BINARY_PATH, 0o755); } catch (err) { - process.stderr.write( - '[understatus] chmod 실패: ' + err.message + '\n' - ); + process.stderr.write('[understatus] chmod 실패: ' + err.message + '\n'); process.exit(1); } @@ -259,7 +332,17 @@ async function main() { ); } -main().catch((err) => { - process.stderr.write('[understatus] 예기치 않은 오류: ' + err.message + '\n'); - process.exit(1); -}); +if (require.main !== module) { + module.exports = { + computeSha256, + download, + expectedChecksum, + extractValidatedBinary, + removeDirQuietly, + }; +} else { + main().catch((err) => { + process.stderr.write('[understatus] 예기치 않은 오류: ' + err.message + '\n'); + process.exit(1); + }); +} diff --git a/npm/package.json b/npm/package.json index ace3822..ee397c2 100644 --- a/npm/package.json +++ b/npm/package.json @@ -5,13 +5,20 @@ "bin": { "understatus": "bin/understatus.js" }, - "os": ["darwin"], - "cpu": ["arm64", "x64"], + "os": [ + "darwin" + ], + "cpu": [ + "arm64", + "x64" + ], "scripts": { - "postinstall": "node install.js" + "postinstall": "node install.js", + "test": "node test-verify-release.js && node test-install.js" }, "files": [ "install.js", + "checksums.json", "bin/understatus.js", "README.md" ], diff --git a/npm/test-install.js b/npm/test-install.js new file mode 100755 index 0000000..7a84296 --- /dev/null +++ b/npm/test-install.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const install = require('./install.js'); + +const TAR = '/usr/bin/tar'; + +function runTar(args) { + const result = spawnSync(TAR, args, { encoding: 'utf8' }); + assert.strictEqual(result.status, 0, result.stderr || result.error && result.error.message); +} + +function makeTarball(build) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'understatus-install-fixture-')); + const tarball = path.join(root, 'fixture.tar.gz'); + const files = path.join(root, 'files'); + fs.mkdirSync(files); + build(files); + const entries = fs.readdirSync(files); + runTar(['-czf', tarball, '-C', files, ...entries]); + return { root, tarball }; +} + +function assertThrowsMessage(fn, pattern) { + let thrown = null; + try { + fn(); + } catch (err) { + thrown = err; + } + assert(thrown, 'expected function to throw'); + assert.match(thrown.message, pattern); +} + +const fixtures = []; +try { + let fixture = makeTarball((files) => { + fs.writeFileSync(path.join(files, 'understatus'), '#!/bin/sh\necho ok\n'); + }); + fixtures.push(fixture.root); + const extracted = install.extractValidatedBinary(fixture.tarball); + assert.strictEqual(fs.readFileSync(extracted, 'utf8'), '#!/bin/sh\necho ok\n'); + assert(path.basename(path.dirname(extracted)).startsWith('.understatus-extract-')); + install.removeDirQuietly(path.dirname(extracted)); + + fixture = makeTarball((files) => { + fs.writeFileSync(path.join(files, 'understatus'), 'ok\n'); + fs.writeFileSync(path.join(files, 'extra'), 'extra\n'); + }); + fixtures.push(fixture.root); + assertThrowsMessage( + () => install.extractValidatedBinary(fixture.tarball), + /예상하지 않은 tarball 항목/ + ); + + fixture = makeTarball((files) => { + fs.symlinkSync('missing-target', path.join(files, 'understatus')); + }); + fixtures.push(fixture.root); + assertThrowsMessage( + () => install.extractValidatedBinary(fixture.tarball), + /regular file이 아닙니다/ + ); + + assertThrowsMessage( + () => install.expectedChecksum('0.7.0', 'unknown-target'), + /체크섬이 없습니다/ + ); + + console.log('install tests passed'); +} finally { + for (const fixtureRoot of fixtures) { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +} diff --git a/npm/test-verify-release.js b/npm/test-verify-release.js new file mode 100755 index 0000000..780907a --- /dev/null +++ b/npm/test-verify-release.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const TAR = '/usr/bin/tar'; + +const repoRoot = path.resolve(__dirname, '..'); +const version = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version; +const targets = ['aarch64-apple-darwin', 'x86_64-apple-darwin']; +const expectedCommit = '0123456789abcdef0123456789abcdef01234567'; + +function copyFile(src, dest) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); +} + +function sha256(filePath) { + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return hash.digest('hex'); +} + +function runTar(args) { + const result = spawnSync(TAR, args, { encoding: 'utf8' }); + assert.strictEqual(result.status, 0, result.stderr || result.error && result.error.message); +} + +function writeTarball(filePath, entries) { + const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'understatus-asset-build-')); + try { + for (const [name, content] of entries) { + fs.writeFileSync(path.join(buildDir, name), content); + } + runTar(['-czf', filePath, '-C', buildDir, ...entries.map(([name]) => name)]); + } finally { + fs.rmSync(buildDir, { recursive: true, force: true }); + } +} + +function runVerify(fixtureRoot, args) { + return spawnSync(process.execPath, [path.join(fixtureRoot, 'npm', 'verify-release.js'), version, ...args], { + cwd: fixtureRoot, + encoding: 'utf8', + }); +} + +function makeFixture() { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'understatus-verify-release-')); + fs.mkdirSync(path.join(fixtureRoot, 'npm', 'bin'), { recursive: true }); + for (const rel of [ + 'npm/verify-release.js', + 'npm/install.js', + 'npm/checksums.json', + 'npm/package.json', + 'npm/bin/understatus.js', + 'npm/README.md', + ]) { + copyFile(path.join(repoRoot, rel), path.join(fixtureRoot, rel)); + } + fs.writeFileSync(path.join(fixtureRoot, 'Cargo.toml'), `[package]\nversion = "${version}"\n`); + return fixtureRoot; +} + +function makeAssets(fixtureRoot) { + const assetDir = path.join(fixtureRoot, 'assets'); + fs.mkdirSync(assetDir, { recursive: true }); + for (const target of targets) { + const name = `understatus-${version}-${target}.tar.gz`; + const filePath = path.join(assetDir, name); + writeTarball(filePath, [['understatus', `artifact:${target}\n`]]); + const checksum = sha256(filePath); + fs.writeFileSync(path.join(assetDir, `${name}.sha256`), `${checksum} ${name}\n`); + fs.writeFileSync( + path.join(assetDir, `${name}.provenance.json`), + JSON.stringify({ + tag: `v${version}`, + commit: expectedCommit, + target, + asset: name, + sha256: checksum, + }, null, 2) + '\n' + ); + } + return assetDir; +} + +const fixtureRoot = makeFixture(); +try { + const assetDir = makeAssets(fixtureRoot); + + let result = runVerify(fixtureRoot, [ + '--tarball-dir', + assetDir, + '--verify-sidecars', + '--verify-provenance', + '--expected-commit', + expectedCommit, + '--write-checksums', + '--verify-packlist', + ]); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + + let result2 = runVerify(fixtureRoot, [ + '--tarball-dir', + assetDir, + '--verify-provenance', + '--expected-commit', + expectedCommit, + '--require-checksums', + '--verify-packlist' + ]); + assert.strictEqual(result2.status, 0, result2.stderr || result2.stdout); + + result2 = runVerify(fixtureRoot, [ + '--tarball-dir', + assetDir, + '--write-checksums', + '--require-checksums', + ]); + assert.notStrictEqual(result2.status, 0, 'write+require combination should fail'); + assert.match(result2.stderr, /separate verify step/); + + const generated = JSON.parse(fs.readFileSync(path.join(fixtureRoot, 'npm', 'checksums.json'), 'utf8')); + for (const target of targets) { + assert.strictEqual( + generated[version][target], + sha256(path.join(assetDir, `understatus-${version}-${target}.tar.gz`)) + ); + } + + makeAssets(fixtureRoot); + const badProvenance = path.join( + assetDir, + `understatus-${version}-aarch64-apple-darwin.tar.gz.provenance.json` + ); + const badProvenancePayload = JSON.parse(fs.readFileSync(badProvenance, 'utf8')); + badProvenancePayload.commit = 'f'.repeat(40); + fs.writeFileSync(badProvenance, JSON.stringify(badProvenancePayload, null, 2) + '\n'); + result = runVerify(fixtureRoot, [ + '--tarball-dir', + assetDir, + '--verify-provenance', + '--expected-commit', + expectedCommit, + ]); + assert.notStrictEqual(result.status, 0, 'provenance commit mismatch should fail'); + assert.match(result.stderr, /provenance commit mismatch/); + + const corruptedSidecar = path.join( + assetDir, + `understatus-${version}-aarch64-apple-darwin.tar.gz.sha256` + ); + fs.writeFileSync(corruptedSidecar, `${'0'.repeat(64)} understatus-${version}-aarch64-apple-darwin.tar.gz\n`); + result = runVerify(fixtureRoot, ['--tarball-dir', assetDir, '--verify-sidecars']); + assert.notStrictEqual(result.status, 0, 'corrupted sidecar should fail'); + assert.match(result.stderr, /sidecar mismatch/); + + makeAssets(fixtureRoot); + const changedTarball = path.join(assetDir, `understatus-${version}-x86_64-apple-darwin.tar.gz`); + writeTarball(changedTarball, [['understatus', 'tampered but installable\n']]); + fs.writeFileSync( + `${changedTarball}.sha256`, + `${sha256(changedTarball)} understatus-${version}-x86_64-apple-darwin.tar.gz\n` + ); + result = runVerify(fixtureRoot, ['--tarball-dir', assetDir, '--require-checksums']); + assert.notStrictEqual(result.status, 0, 'tarball/checksum mismatch should fail'); + assert.match(result.stderr, /checksum mismatch/); + + makeAssets(fixtureRoot); + const malformedTarball = path.join(assetDir, `understatus-${version}-aarch64-apple-darwin.tar.gz`); + writeTarball(malformedTarball, [ + ['understatus', 'ok\n'], + ['extra', 'not allowed\n'], + ]); + fs.writeFileSync( + `${malformedTarball}.sha256`, + `${sha256(malformedTarball)} understatus-${version}-aarch64-apple-darwin.tar.gz\n` + ); + result = runVerify(fixtureRoot, ['--tarball-dir', assetDir, '--verify-sidecars', '--write-checksums']); + assert.notStrictEqual(result.status, 0, 'malformed tarball layout should fail'); + assert.match(result.stderr, /tarball layout must contain exactly understatus/); + + const pkgPath = path.join(fixtureRoot, 'npm', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + pkg.files = pkg.files.filter((entry) => entry !== 'checksums.json'); + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + result = runVerify(fixtureRoot, ['--verify-packlist']); + assert.notStrictEqual(result.status, 0, 'missing checksums.json in packlist should fail'); + assert.match(result.stderr, /packlist missing required file: checksums\.json/); + + result = spawnSync(process.execPath, [path.join(fixtureRoot, 'npm', 'verify-release.js'), '1.2.3.4'], { + cwd: fixtureRoot, + encoding: 'utf8', + }); + assert.notStrictEqual(result.status, 0, 'invalid semver should fail'); + + console.log('verify-release tests passed'); +} finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); +} diff --git a/npm/verify-release.js b/npm/verify-release.js new file mode 100755 index 0000000..d34a6a9 --- /dev/null +++ b/npm/verify-release.js @@ -0,0 +1,401 @@ +#!/usr/bin/env node +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..'); +const SUPPORTED_TARGETS = ['aarch64-apple-darwin', 'x86_64-apple-darwin']; +const CHECKSUMS_PATH = path.join(__dirname, 'checksums.json'); +const TAR = '/usr/bin/tar'; + +function fail(message) { + console.error('[understatus release verify] ' + message); + process.exit(1); +} + +function usage() { + fail( + 'usage: node npm/verify-release.js ' + + '[--target ] [--tarball-dir ] ' + + '[--require-checksums] [--write-checksums] [--verify-sidecars] [--verify-provenance] ' + + '[--expected-commit ] [--verify-packlist]' + ); +} + +function parseArgs(argv) { + const expected = argv[0]; + const options = { + expected, + target: null, + tarballDir: null, + requireChecksums: false, + writeChecksums: false, + verifySidecars: false, + verifyProvenance: false, + expectedCommit: null, + verifyPacklist: false, + }; + + for (let i = 1; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--target') { + if (!argv[i + 1] || argv[i + 1].startsWith('--')) { + usage(); + } + options.target = argv[i + 1]; + i += 1; + } else if (arg === '--tarball-dir') { + if (!argv[i + 1] || argv[i + 1].startsWith('--')) { + usage(); + } + options.tarballDir = argv[i + 1]; + i += 1; + } else if (arg === '--require-checksums') { + options.requireChecksums = true; + } else if (arg === '--write-checksums') { + options.writeChecksums = true; + } else if (arg === '--verify-sidecars') { + options.verifySidecars = true; + } else if (arg === '--verify-provenance') { + options.verifyProvenance = true; + } else if (arg === '--expected-commit') { + if (!argv[i + 1] || argv[i + 1].startsWith('--')) { + usage(); + } + options.expectedCommit = argv[i + 1]; + i += 1; + } else if (arg === '--verify-packlist') { + options.verifyPacklist = true; + } else { + usage(); + } + } + + return options; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (err) { + fail(`${filePath} is not readable JSON: ${err.message}`); + } +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n'); +} + +function computeSha256(filePath) { + const hash = crypto.createHash('sha256'); + hash.update(fs.readFileSync(filePath)); + return hash.digest('hex'); +} + +function tarballName(version, target) { + return `understatus-${version}-${target}.tar.gz`; +} + +function tarballPath(version, target, tarballDir) { + return path.join(tarballDir, tarballName(version, target)); +} + +function sidecarPath(version, target, tarballDir) { + return path.join(tarballDir, `${tarballName(version, target)}.sha256`); +} + +function provenancePath(version, target, tarballDir) { + return path.join(tarballDir, `${tarballName(version, target)}.provenance.json`); +} + +function requireFile(filePath, label) { + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + fail(`${label} not found: ${filePath}`); + } +} + +function removeDirQuietly(dirPath) { + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + } catch (_) { + // best-effort cleanup + } +} + +function verifyTarballLayout(filePath) { + const listResult = spawnSync(TAR, ['-tzf', filePath], { + encoding: 'utf8', + }); + if (listResult.error) { + fail(`tar list failed to start for ${filePath}: ${listResult.error.message}`); + } + if (listResult.status !== 0) { + fail(`tar list failed for ${filePath} (${listResult.status}): ${listResult.stderr}`); + } + + const entries = listResult.stdout + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + if (entries.length !== 1 || entries[0] !== 'understatus') { + fail( + `tarball layout must contain exactly understatus: ${filePath} has ` + + (entries.length ? entries.join(', ') : '(no entries)') + ); + } + + const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'understatus-verify-release-')); + try { + const extractResult = spawnSync(TAR, ['-xzf', filePath, '-C', extractDir, 'understatus'], { + encoding: 'utf8', + }); + if (extractResult.error) { + fail(`tar extract failed to start for ${filePath}: ${extractResult.error.message}`); + } + if (extractResult.status !== 0) { + fail(`tar extract failed for ${filePath} (${extractResult.status}): ${extractResult.stderr}`); + } + + const extractedPath = path.join(extractDir, 'understatus'); + let stat; + try { + stat = fs.lstatSync(extractedPath); + } catch (err) { + fail(`tarball did not extract understatus from ${filePath}: ${err.message}`); + } + if (!stat.isFile()) { + fail(`tarball understatus entry is not a regular file: ${filePath}`); + } + } finally { + removeDirQuietly(extractDir); + } +} + +function readSidecarChecksum(filePath, expectedTarballName) { + requireFile(filePath, 'expected release checksum sidecar'); + const line = fs.readFileSync(filePath, 'utf8').trim(); + const match = line.match(/^([0-9a-f]{64})\s+(.+)$/); + if (!match) { + fail(`invalid sha256 sidecar format: ${filePath}`); + } + if (path.basename(match[2]) !== expectedTarballName) { + fail( + `sha256 sidecar ${filePath} names ${match[2]}, expected ${expectedTarballName}` + ); + } + return match[1]; +} + + +function verifyProvenance(version, target, tarballDir, expectedCommit) { + const artifactName = tarballName(version, target); + const artifactPath = tarballPath(version, target, tarballDir); + const expectedTag = `v${version}`; + const normalizedExpectedCommit = expectedCommit.toLowerCase(); + const provenanceFile = provenancePath(version, target, tarballDir); + requireFile(provenanceFile, 'expected release provenance'); + const provenance = readJson(provenanceFile); + + if (provenance.tag !== expectedTag) { + fail(`provenance tag mismatch for ${artifactName}: ${provenance.tag} != ${expectedTag}`); + } + if ((provenance.commit || '').toLowerCase() !== normalizedExpectedCommit) { + fail( + `provenance commit mismatch for ${artifactName}: ` + + `${provenance.commit || '(missing)'} != ${normalizedExpectedCommit}` + ); + } + if (provenance.target !== target) { + fail(`provenance target mismatch for ${artifactName}: ${provenance.target} != ${target}`); + } + if (provenance.asset !== artifactName) { + fail(`provenance asset mismatch for ${artifactName}: ${provenance.asset} != ${artifactName}`); + } + + const actualChecksum = computeSha256(artifactPath); + if (provenance.sha256 !== actualChecksum) { + fail( + `provenance sha256 mismatch for ${artifactName}: ` + + `${provenance.sha256 || '(missing)'} != ${actualChecksum}` + ); + } +} + +function verifyPacklist() { + const result = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: __dirname, + encoding: 'utf8', + }); + if (result.error) { + fail(`npm pack --dry-run failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + fail(`npm pack --dry-run failed (${result.status}): ${result.stderr}`); + } + + let payload; + try { + payload = JSON.parse(result.stdout); + } catch (err) { + fail(`npm pack --dry-run did not return JSON: ${err.message}`); + } + const files = (((payload || [])[0] || {}).files || []).map((entry) => entry.path); + for (const required of ['checksums.json', 'install.js', 'bin/understatus.js']) { + if (!files.includes(required)) { + fail(`npm packlist missing required file: ${required}`); + } + } +} + +const options = parseArgs(process.argv.slice(2)); +const expected = options.expected; + +if (!expected || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(expected)) { + usage(); +} + +if (options.target && !SUPPORTED_TARGETS.includes(options.target)) { + fail(`unsupported target ${options.target}; expected one of ${SUPPORTED_TARGETS.join(', ')}`); +} + +if ((options.writeChecksums || options.verifySidecars || options.verifyProvenance) && !options.tarballDir) { + fail('--write-checksums, --verify-sidecars, and --verify-provenance require --tarball-dir'); +} +if (options.expectedCommit && !/^[0-9a-fA-F]{40}$/.test(options.expectedCommit)) { + fail('--expected-commit must be a full 40-character hex commit SHA'); +} +if (options.verifyProvenance && !options.expectedCommit) { + fail('--verify-provenance requires --expected-commit'); +} +if (options.writeChecksums && options.requireChecksums) { + fail('--write-checksums generates a package manifest; run --require-checksums in a separate verify step'); +} + +if (options.tarballDir) { + options.tarballDir = path.resolve(options.tarballDir); + if (!fs.existsSync(options.tarballDir) || !fs.statSync(options.tarballDir).isDirectory()) { + fail(`--tarball-dir is not a directory: ${options.tarballDir}`); + } +} + +const cargoToml = fs.readFileSync(path.join(repoRoot, 'Cargo.toml'), 'utf8'); +const cargoMatch = cargoToml.match(/^version\s*=\s*"([^"]+)"/m); +if (!cargoMatch) { + fail('Cargo.toml version not found'); +} +if (cargoMatch[1] !== expected) { + fail(`Cargo.toml version ${cargoMatch[1]} does not match tag ${expected}`); +} + +const packageJson = readJson(path.join(__dirname, 'package.json')); +if (packageJson.version !== expected) { + fail(`npm/package.json version ${packageJson.version} does not match tag ${expected}`); +} + +const installJs = fs.readFileSync(path.join(__dirname, 'install.js'), 'utf8'); +const installMatch = installJs.match(/const VERSION = '([^']+)'/); +if (!installMatch) { + fail('install.js VERSION not found'); +} +if (installMatch[1] !== expected) { + fail(`install.js VERSION ${installMatch[1]} does not match tag ${expected}`); +} + +const targetsToCheck = options.target ? [options.target] : SUPPORTED_TARGETS; +let checksums = readJson(CHECKSUMS_PATH); + +if (options.tarballDir) { + for (const target of targetsToCheck) { + const artifactPath = tarballPath(expected, target, options.tarballDir); + requireFile(artifactPath, 'expected release artifact'); + verifyTarballLayout(artifactPath); + } +} + +if (options.verifyProvenance) { + for (const target of targetsToCheck) { + verifyProvenance(expected, target, options.tarballDir, options.expectedCommit); + } +} + +if (options.verifySidecars) { + for (const target of targetsToCheck) { + const artifactName = tarballName(expected, target); + const artifactPath = tarballPath(expected, target, options.tarballDir); + requireFile(artifactPath, 'expected release artifact'); + const actualChecksum = computeSha256(artifactPath); + const sidecarChecksum = readSidecarChecksum( + sidecarPath(expected, target, options.tarballDir), + artifactName + ); + if (sidecarChecksum !== actualChecksum) { + fail( + `sha256 sidecar mismatch for ${artifactName}: ` + + `sidecar=${sidecarChecksum} actual=${actualChecksum}` + ); + } + } +} + +if (options.writeChecksums) { + const releaseChecksums = Object.assign({}, checksums[expected] || {}); + for (const target of targetsToCheck) { + const artifactPath = tarballPath(expected, target, options.tarballDir); + requireFile(artifactPath, 'expected release artifact'); + releaseChecksums[target] = computeSha256(artifactPath); + } + checksums = Object.assign({}, checksums, { [expected]: releaseChecksums }); + writeJson(CHECKSUMS_PATH, checksums); +} + +const releaseChecksums = checksums[expected]; +if (!releaseChecksums) { + if (options.requireChecksums || options.tarballDir) { + fail(`checksums.json has no entry for ${expected}`); + } +} else { + for (const target of targetsToCheck) { + const checksum = releaseChecksums[target]; + if (!/^[0-9a-f]{64}$/.test(checksum || '')) { + fail(`checksums.json missing valid sha256 for ${expected}/${target}`); + } + + if (options.tarballDir) { + const artifactPath = tarballPath(expected, target, options.tarballDir); + requireFile(artifactPath, 'expected release artifact'); + const actualChecksum = computeSha256(artifactPath); + if (actualChecksum !== checksum) { + fail( + `checksum mismatch for ${tarballName(expected, target)}: ` + + `checksums.json=${checksum} actual=${actualChecksum}` + ); + } + } + } +} + +if (options.verifyPacklist) { + verifyPacklist(); +} + +const checked = ['versions']; +if (releaseChecksums || options.requireChecksums || options.writeChecksums) { + checked.push('manifest'); +} +if (options.tarballDir) { + checked.push('release artifacts', 'tarball layout'); +} +if (options.verifySidecars) { + checked.push('sha256 sidecars'); +} +if (options.verifyProvenance) { + checked.push('provenance'); +} +if (options.verifyPacklist) { + checked.push('npm packlist'); +} +console.log(`[understatus release verify] ${expected} ${checked.join(', ')} are consistent`); diff --git a/src/codex.rs b/src/codex.rs index 34ea51d..5acf384 100644 --- a/src/codex.rs +++ b/src/codex.rs @@ -8,7 +8,8 @@ //! 설계 원칙(spec §2): //! 1. fail-safe + "모호한 성공도 실패로 취급"(잘못된 세션을 자신 있게 표시하는 fail-wrong 금지). //! 2. 디스크 I/O·스캔·tail은 본 모듈에 격리(claude.rs::parse_lterm_input은 순수 유지). -//! 3. 바운디드 비용: 전체 파싱 금지(head 16KB + tail 256KB), 디스크 캐시로 정상상태 stat 1회. +//! 3. 바운디드 비용: 전체 파싱 금지(head 16KB + tail 256KB), 디스크 캐시로 정상상태는 +//! 최근 일자 디렉터리 mtime + 캐시 경로 stat만 확인. //! 4. opt-out: `[codex] enabled=false`면 `~/.codex` 일절 안 읽음. //! 5. 모든 필드 lenient(Option): 부재/타입 드리프트/cli_version 변동 시 무패닉 → 세그먼트 생략. @@ -20,7 +21,7 @@ use crate::claude::ClaudeInput; use crate::config::Config; use serde::{Deserialize, Serialize}; use std::fs::File; -use std::io::{Read, Seek, SeekFrom}; +use std::io::{BufRead, BufReader, Error, ErrorKind, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -33,6 +34,17 @@ const FIRST_LINE_READ_BYTES: u64 = 128 * 1024; /// tail(뒷부분) 읽기 상한. 마지막 token_count + 최신 turn_context용(실측 gap max 14KB, /// 단일 라인 max 132KB → 256KB 안전마진, spec §8.1). const TAIL_READ_BYTES: u64 = 256 * 1024; +/// 최근 일자 디렉터리에서 한 번의 해소가 검사할 디렉터리 엔트리 총량 상한. +/// +/// 상한을 넘기면 단일 후보를 확정하지 않고 fail-safe(ambiguous와 동일하게 enrich 생략)로 +/// 저하한다. 그래야 큰 `sessions` 트리에서 statusline hot path가 무한히 파일을 stat/read하지 +/// 않고, 부분 스캔으로 잘못된 단일 세션을 표시하는 fail-wrong도 피한다. +const MAX_CODEX_SCAN_ENTRIES: usize = 1024; +/// `sessions///` discovery에서 한 디렉터리 레벨마다 검사할 엔트리 상한. +/// +/// 후보 파일 스캔 예산 전에 year/month/day discovery가 거대한 가짜 트리에 끌려가지 않도록 +/// 별도 상한을 둔다. 초과 시 단일 후보를 확정하지 않고 fail-safe로 저하한다. +const MAX_CODEX_DATE_DIR_ENTRIES: usize = 512; /// 디스크 캐시 파일명(세션별 격리, chain.rs 인프라 재사용, spec §8). const CODEX_CACHE_FILE: &str = "codex_session"; /// 대화형(TUI) originator 화이트리스트 prefix. `codex_exec` 등 비대화형은 제외(spec §5). @@ -85,6 +97,56 @@ pub enum Resolution { None, } +/// 후보 스캔 결과와 캐시 재사용 안전성을 판정하기 위한 최근 rollout 트리 지문. +#[derive(Debug, Clone, PartialEq)] +struct CandidateScan { + /// cwd/originator/freshness 선필터를 통과한 후보. + candidates: Vec, + /// 현재는 stale이라 후보에서 제외됐지만 cwd/originator가 같은 rollout. 나중에 fresh가 + /// 되면 기존 단일 캐시를 재사용하면 안 되므로 cache hit에서 감시한다. + stale_same_cwd_rollouts: Vec, + /// 최근 일자 디렉터리 mtime 지문. + fingerprint: CodexScanFingerprint, + /// `MAX_CODEX_SCAN_ENTRIES`를 초과해 전체 후보 유일성을 증명할 수 없는 상태. + budget_exceeded: bool, + /// read_dir/metadata 실패로 scan view가 완전하지 않은 상태. + scan_incomplete: bool, +} + +struct RecentDayDirs { + dirs: Vec, + budget_exceeded: bool, + scan_incomplete: bool, +} + +struct BoundedSubdirs { + dirs: Vec, + budget_exceeded: bool, + scan_incomplete: bool, +} + +/// 캐시된 단일 후보가 여전히 "유일한 후보"인지 재검증하기 위한 경량 일자 지문. +/// +/// 캐시 히트마다 rollout 파일들을 다시 순회하지는 않고, 최근 일자 디렉터리 mtime만 확인해 +/// 새 rollout 생성/삭제를 감지한다. 지문이 바뀌면 캐시를 바로 재사용하지 않고 후보 스캔으로 +/// 모호성을 다시 판정한다. 기존 stale 동일-cwd rollout이 fresh로 바뀌는 경우는 별도 watch-list로 +/// 감지한다. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CodexScanFingerprint { + days: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct CodexDayFingerprint { + path: String, + mtime_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct WatchedRollout { + path: String, +} + /// session_meta(첫 줄)에서 추출한 매칭 근거. #[derive(Debug, Clone, PartialEq)] struct SessionMeta { @@ -282,16 +344,24 @@ fn is_interactive_originator(originator: Option<&str>) -> bool { .unwrap_or(false) } +/// cwd 비교용 정규화. 존재 경로는 canonicalize하고, 부재 경로는 trailing slash만 제거한다. +fn normalize_cwd_for_match(cwd: &str) -> PathBuf { + std::fs::canonicalize(cwd).unwrap_or_else(|_| PathBuf::from(cwd.trim_end_matches('/'))) +} + +/// 반복 후보 스캔에서 target cwd canonicalize를 매 후보마다 반복하지 않기 위한 비교 헬퍼. +fn cwd_matches_normalized(candidate_cwd: &str, normalized_target_cwd: &Path) -> bool { + normalize_cwd_for_match(candidate_cwd) == normalized_target_cwd +} + /// 두 cwd 문자열이 같은 디렉터리를 가리키는지 비교한다(정규화: canonicalize 실패 시 trim 비교). /// /// 외부 입력(payload.cwd)은 **비교에만** 쓰고 파일경로 구성엔 쓰지 않는다(traversal 무관, spec §5). /// trailing slash 등 표기 차이를 흡수하기 위해 canonicalize를 시도하고, 실패(부재 경로 등) 시 /// trim된 문자열 동치로 폴백한다. +#[cfg(test)] fn cwd_matches(candidate_cwd: &str, target_cwd: &str) -> bool { - let normalize = |p: &str| -> PathBuf { - std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p.trim_end_matches('/'))) - }; - normalize(candidate_cwd) == normalize(target_cwd) + cwd_matches_normalized(candidate_cwd, &normalize_cwd_for_match(target_cwd)) } // ============================== 발견/IO(spec §5/§8.1) ============================== @@ -425,6 +495,7 @@ fn extract_from_file(path: &Path) -> Option { /// # 반환 /// 부합 후보 경로들. 1) 최근 scan_days 일자만(전체 4400+ 회피) 2) mtime freshness 선필터 /// 3) 첫 줄 cwd 정규화 일치 AND originator 화이트리스트. 외부 cwd는 비교에만 사용한다. +#[cfg(test)] fn find_codex_candidates( base: &Path, cwd: &str, @@ -432,128 +503,347 @@ fn find_codex_candidates( freshness: u64, scan_days: usize, ) -> Vec { - let sessions_dir = base.join("sessions"); - let day_dirs = recent_day_dirs(&sessions_dir, scan_days); + scan_codex_candidates(base, cwd, now, freshness, scan_days).candidates +} +/// 후보 스캔 + rollout 트리 지문 계산을 한 번에 수행한다. +/// +/// 지문은 캐시 히트 시 새 rollout 추가/삭제 여부를 확인하는 데 쓰이고, `budget_exceeded` +/// 는 부분 스캔으로 단일 후보를 확정하지 않도록 fail-safe 판정에 쓰인다. +fn scan_codex_candidates( + base: &Path, + cwd: &str, + now: SystemTime, + freshness: u64, + scan_days: usize, +) -> CandidateScan { + let (rollout_paths, fingerprint, budget_exceeded, mut scan_incomplete) = + collect_rollout_scan(base, scan_days, true); let freshness_secs = freshness.saturating_mul(60); + let normalized_target_cwd = normalize_cwd_for_match(cwd); let mut candidates = Vec::new(); - for day_dir in day_dirs { - let entries = match std::fs::read_dir(&day_dir) { - Ok(entries) => entries, - Err(_) => continue, + let mut stale_same_cwd_rollouts = Vec::new(); + + if budget_exceeded || scan_incomplete { + return CandidateScan { + candidates, + stale_same_cwd_rollouts, + fingerprint, + budget_exceeded, + scan_incomplete, }; - for entry in entries.flatten() { - let path = entry.path(); - if !is_rollout_file(&path) { - continue; - } - // (2) cheap stat 선필터: mtime이 freshness 이내인 후보만. - if !is_fresh(&path, now, freshness_secs) { - continue; + } + + for path in rollout_paths { + // (3) 첫 줄 session_meta만 읽어 cwd 일치 + 대화형 originator를 확인한다. + // stale 동일-cwd 파일도 기록해 둔다. 기존 파일 append는 day-dir mtime을 바꾸지 않을 수 있어, + // 나중에 fresh가 됐을 때 캐시 히트가 새 모호성을 놓치지 않도록 하기 위함이다. + let fresh = match is_fresh_checked(&path, now, freshness_secs) { + Ok(fresh) => fresh, + Err(_) => { + scan_incomplete = true; + break; } - // (3) 첫 줄 session_meta만 읽어 cwd 일치 + 대화형 originator를 확인한다. - if let Some(meta) = read_first_line_meta(&path) { + }; + match read_first_line_meta_checked(&path) { + Ok(Some(meta)) => { let cwd_ok = meta .cwd .as_deref() - .map(|c| cwd_matches(c, cwd)) + .map(|c| cwd_matches_normalized(c, &normalized_target_cwd)) .unwrap_or(false); let originator_ok = is_interactive_originator(meta.originator.as_deref()); - if cwd_ok && originator_ok { + if fresh && cwd_ok && originator_ok { candidates.push(path); + } else if !fresh && cwd_ok && originator_ok { + stale_same_cwd_rollouts.push(WatchedRollout { + path: path.to_string_lossy().into_owned(), + }); + } + } + Ok(None) => {} + Err(_) => { + scan_incomplete = true; + break; + } + } + } + CandidateScan { + candidates, + stale_same_cwd_rollouts, + fingerprint, + budget_exceeded, + scan_incomplete, + } +} + +/// 후보 본문/파일 엔트리를 읽지 않고 최근 일자 디렉터리의 캐시 검증용 지문만 계산한다. +fn current_scan_fingerprint(base: &Path, scan_days: usize) -> Option { + let (_rollout_paths, fingerprint, budget_exceeded, scan_incomplete) = + collect_rollout_scan(base, scan_days, false); + if budget_exceeded || scan_incomplete { + return None; + } + Some(fingerprint) +} + +/// 최근 일자 디렉터리의 rollout 파일 경로(선택)와 스캔 지문을 수집한다. +fn collect_rollout_scan( + base: &Path, + scan_days: usize, + collect_paths: bool, +) -> (Vec, CodexScanFingerprint, bool, bool) { + let sessions_dir = base.join("sessions"); + let recent = recent_day_dirs(&sessions_dir, scan_days); + let mut rollout_paths = Vec::new(); + let mut days = Vec::new(); + let mut entries_seen = 0usize; + let mut budget_exceeded = recent.budget_exceeded; + let mut scan_incomplete = recent.scan_incomplete; + + for day_dir in recent.dirs { + let day = match codex_day_fingerprint_checked(&day_dir) { + Ok(day) => day, + Err(_) => { + scan_incomplete = true; + CodexDayFingerprint { + path: day_dir.to_string_lossy().into_owned(), + mtime_ms: None, + } + } + }; + if budget_exceeded || scan_incomplete { + days.push(day); + break; + } + let entries = match std::fs::read_dir(&day_dir) { + Ok(entries) => entries, + Err(_) => { + scan_incomplete = true; + days.push(day); + break; + } + }; + for entry_result in entries { + let entry = match entry_result { + Ok(entry) => entry, + Err(_) => { + scan_incomplete = true; + break; + } + }; + if entries_seen >= MAX_CODEX_SCAN_ENTRIES { + budget_exceeded = true; + break; + } + entries_seen += 1; + + let path = entry.path(); + match is_rollout_file(&path) { + Ok(true) if collect_paths => rollout_paths.push(path), + Ok(_) => {} + Err(_) => { + scan_incomplete = true; + break; } } } + days.push(day); + if budget_exceeded || scan_incomplete { + break; + } } - candidates + + ( + rollout_paths, + CodexScanFingerprint { days }, + budget_exceeded, + scan_incomplete, + ) +} + +fn codex_day_fingerprint_checked(day_dir: &Path) -> std::io::Result { + Ok(CodexDayFingerprint { + path: day_dir.to_string_lossy().into_owned(), + mtime_ms: Some(file_mtime_ms_checked(day_dir)?), + }) } /// `sessions///` 트리에서 최근 `scan_days`개 일자 디렉터리를 내림차순으로 모은다. /// /// 연도 desc → 월 desc → 일 desc로 정렬해 최신부터 `scan_days`개만 취한다(전체 풀스캔 회피, spec §5). /// 폴더는 세션 시작시각 기준이라 scan_days 밖 장기 활성 세션은 미발견(알려진 한계, spec §10 S1). -fn recent_day_dirs(sessions_dir: &Path, scan_days: usize) -> Vec { +fn recent_day_dirs(sessions_dir: &Path, scan_days: usize) -> RecentDayDirs { let mut result = Vec::new(); if scan_days == 0 { - return result; + return RecentDayDirs { + dirs: result, + budget_exceeded: false, + scan_incomplete: false, + }; } // 연도 디렉터리 내림차순. - for year_dir in sorted_subdirs_desc(sessions_dir) { - for month_dir in sorted_subdirs_desc(&year_dir) { - for day_dir in sorted_subdirs_desc(&month_dir) { + let year_dirs = sorted_subdirs_desc_bounded(sessions_dir); + if year_dirs.budget_exceeded || year_dirs.scan_incomplete { + return RecentDayDirs { + dirs: result, + budget_exceeded: year_dirs.budget_exceeded, + scan_incomplete: year_dirs.scan_incomplete, + }; + } + for year_dir in year_dirs.dirs { + let month_dirs = sorted_subdirs_desc_bounded(&year_dir); + if month_dirs.budget_exceeded || month_dirs.scan_incomplete { + return RecentDayDirs { + dirs: result, + budget_exceeded: month_dirs.budget_exceeded, + scan_incomplete: month_dirs.scan_incomplete, + }; + } + for month_dir in month_dirs.dirs { + let day_dirs = sorted_subdirs_desc_bounded(&month_dir); + if day_dirs.budget_exceeded || day_dirs.scan_incomplete { + return RecentDayDirs { + dirs: result, + budget_exceeded: day_dirs.budget_exceeded, + scan_incomplete: day_dirs.scan_incomplete, + }; + } + for day_dir in day_dirs.dirs { result.push(day_dir); if result.len() >= scan_days { - return result; + return RecentDayDirs { + dirs: result, + budget_exceeded: false, + scan_incomplete: false, + }; } } } } - result + RecentDayDirs { + dirs: result, + budget_exceeded: false, + scan_incomplete: false, + } } /// 디렉터리의 하위 디렉터리를 이름 내림차순으로 반환한다(`2026` > `2025`, `06` > `05`). /// /// 이름이 zero-padded 날짜(`06`/`05`/`30`)라 문자열 desc 정렬이 곧 시간 desc와 일치한다. -fn sorted_subdirs_desc(dir: &Path) -> Vec { +fn sorted_subdirs_desc_bounded(dir: &Path) -> BoundedSubdirs { let mut subdirs: Vec = match std::fs::read_dir(dir) { - Ok(entries) => entries - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_dir()) - .collect(), - Err(_) => return Vec::new(), + Ok(entries) => { + let mut subdirs = Vec::new(); + for (entries_seen, entry_result) in entries.enumerate() { + if entries_seen >= MAX_CODEX_DATE_DIR_ENTRIES { + return BoundedSubdirs { + dirs: Vec::new(), + budget_exceeded: true, + scan_incomplete: false, + }; + } + let entry = match entry_result { + Ok(entry) => entry, + Err(_) => { + return BoundedSubdirs { + dirs: Vec::new(), + budget_exceeded: false, + scan_incomplete: true, + }; + } + }; + let path = entry.path(); + match path.metadata() { + Ok(meta) if meta.is_dir() => subdirs.push(path), + Ok(_) => {} + Err(_) => { + return BoundedSubdirs { + dirs: Vec::new(), + budget_exceeded: false, + scan_incomplete: true, + }; + } + } + } + subdirs + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(_) => { + return BoundedSubdirs { + dirs: Vec::new(), + budget_exceeded: false, + scan_incomplete: true, + }; + } }; // 이름 내림차순(최신 우선). file_name 기준으로 비교한다. subdirs.sort_by(|a, b| b.file_name().cmp(&a.file_name())); - subdirs + BoundedSubdirs { + dirs: subdirs, + budget_exceeded: false, + scan_incomplete: false, + } } /// 경로가 `rollout-*.jsonl` 형식인지 판정한다. -fn is_rollout_file(path: &Path) -> bool { - if !path.is_file() { - return false; - } +fn is_rollout_file(path: &Path) -> std::io::Result { let name = match path.file_name().and_then(|n| n.to_str()) { Some(name) => name, - None => return false, + None => return Ok(false), }; - name.starts_with("rollout-") && name.ends_with(".jsonl") + if !(name.starts_with("rollout-") && name.ends_with(".jsonl")) { + return Ok(false); + } + path.metadata().map(|meta| meta.is_file()) } -/// 파일 mtime이 `now`로부터 `freshness_secs` 이내인지 판정한다(cheap stat 선필터). -/// -/// 시계 이상/메타데이터 부재 시 보수적으로 `false`(제외). 미래 mtime(now보다 나중)은 fresh로 본다. -fn is_fresh(path: &Path, now: SystemTime, freshness_secs: u64) -> bool { - let modified = match path.metadata().and_then(|m| m.modified()) { - Ok(m) => m, - Err(_) => return false, - }; +/// 파일 freshness의 fallible variant. 후보 스캔에서는 stat/mtime 실패를 단순 stale로 숨기지 않고 +/// scan_incomplete로 전파해 fail-safe Ambiguous로 저하시킨다. +fn is_fresh_checked(path: &Path, now: SystemTime, freshness_secs: u64) -> std::io::Result { + let modified = path.metadata().and_then(|m| m.modified())?; match now.duration_since(modified) { // mtime이 과거: 경과가 freshness 이내면 fresh. - Ok(elapsed) => elapsed.as_secs() <= freshness_secs, + Ok(elapsed) => Ok(elapsed.as_secs() <= freshness_secs), // mtime이 미래(now보다 나중): 동시 쓰기 등 → fresh로 본다. - Err(_) => true, + Err(_) => Ok(true), } } /// rollout 파일의 첫 줄(session_meta)만 읽어 cwd/originator를 파싱한다(매칭 선필터용). /// /// 첫 줄은 inline `base_instructions` 때문에 실측 ~33KB에 달하므로([`FIRST_LINE_READ_BYTES`] -/// 참조), 그 상한까지 읽되 **개행으로 완결된 첫 줄이 잡힐 때만** 파싱한다. 개행 미발견(상한 내 -/// 첫 줄 미완결)이면 부분 JSON을 파싱하지 않고 `None`(무패닉, 보수적 제외). 읽기/파싱 실패도 `None`. +/// 참조), 그 상한까지 읽되 **개행으로 완결된 첫 줄이 잡힐 때만** 파싱한다. legacy bool API는 +/// 읽기/상한 실패를 `None`으로 숨기지만, 후보 스캔은 fallible variant로 scan_incomplete를 보존한다. +#[cfg(test)] fn read_first_line_meta(path: &Path) -> Option { - let mut file = File::open(path).ok()?; - // 첫 줄은 base_instructions로 커지므로(실측 ~33KB) 별도의 넉넉한 상한까지 읽는다. - let mut buf = vec![0u8; FIRST_LINE_READ_BYTES as usize]; - read_exact_lossy(&mut file, &mut buf)?; - let text = String::from_utf8_lossy(&buf); - // 개행으로 완결된 첫 줄만 신뢰한다(부분 라인 파싱 금지). 파일 전체가 한 줄(개행 부재)이고 - // 상한 미만이면 그 전체를 첫 줄로 본다(작은 파일 안전 처리). - let first_line = match text.split_once('\n') { - Some((line, _)) => line, - None => text.as_ref(), - }; - parse_session_meta(first_line) + read_first_line_meta_checked(path).ok().flatten() +} + +/// [`read_first_line_meta`]의 fallible variant. 파일 IO 실패 또는 상한 안에 완결 개행이 없는 +/// partial 첫 줄은 후보 없음으로 숨기지 않고 scan_incomplete로 전파한다. +fn read_first_line_meta_checked(path: &Path) -> Result, ()> { + let file = File::open(path).map_err(|_| ())?; + let reader = BufReader::new(file); + // `read_until`은 개행을 만날 때까지만 버퍼를 키운다. 이전처럼 후보마다 고정 128KiB를 + // 선할당하지 않으므로 다수 후보 스캔에서 불필요한 메모리 churn을 줄인다. + let mut limited = reader.take(FIRST_LINE_READ_BYTES); + let mut buf = Vec::with_capacity(4 * 1024); + let bytes_read = limited.read_until(b'\n', &mut buf).map_err(|_| ())?; + if bytes_read == 0 { + return Ok(None); + } + let has_newline = buf.last() == Some(&b'\n'); + if !has_newline { + // newline으로 완결되지 않은 첫 줄은 길이와 무관하게 부분 라인으로 본다. + return Err(()); + } + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + let first_line = String::from_utf8_lossy(&buf); + parse_session_meta(&first_line).map(Some).ok_or(()) } /// 후보를 스캔하고 모호성을 판정해 [`Resolution`]으로 해소한다(spec §5). @@ -569,11 +859,19 @@ fn read_codex_session( freshness: u64, scan_days: usize, ) -> Resolution { - let candidates = find_codex_candidates(base, cwd, now, freshness, scan_days); - match candidates.len() { + let scan = scan_codex_candidates(base, cwd, now, freshness, scan_days); + resolution_from_scan(&scan) +} + +/// 완료된 후보 스캔을 단일/모호/없음 해소 결과로 변환한다. +fn resolution_from_scan(scan: &CandidateScan) -> Resolution { + if scan.budget_exceeded || scan.scan_incomplete { + return Resolution::Ambiguous; + } + match scan.candidates.len() { 0 => Resolution::None, 1 => { - let path = &candidates[0]; + let path = &scan.candidates[0]; match extract_from_file(path) { Some(session) => Resolution::Single(session, path.clone()), None => Resolution::None, @@ -588,25 +886,39 @@ fn read_codex_session( /// 캐시 본문(serde_json 1라인 직렬화). 해소된 rollout 경로 + mtime + 파싱 결과. /// -/// 정상상태(경로 mtime 불변 & freshness 이내)에는 stat 1회로 재사용된다(spec §8). 역직렬화 -/// 실패(스키마 드리프트)는 lenient로 무시 → 풀 재해소(캐시 버저닝 불필요). +/// 정상상태(cwd 동일, 최근 일자 디렉터리 mtime 불변, 경로 mtime 불변 & freshness 이내)에는 +/// 후보 파일 본문을 다시 읽지 않고 재사용된다(spec §8). 역직렬화 실패(스키마 드리프트)는 +/// lenient로 무시 → 풀 재해소(캐시 버저닝 불필요). #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexCacheEntry { /// 해소된 rollout 파일 경로. path: String, /// 그 파일의 mtime(epoch ms). 불변이면 재파싱 없이 재사용. mtime_ms: u128, + /// 단일 후보를 확정했던 호출의 정규화 cwd. 다른 cwd에는 캐시를 재사용하지 않는다. + #[serde(default)] + matched_cwd: Option, + /// 단일 후보를 확정했던 시점의 최근 rollout 트리 지문. + #[serde(default)] + scan_fingerprint: Option, + /// 캐시 생성 시 동일 cwd/originator였지만 stale이라 제외된 rollout들. + #[serde(default)] + stale_same_cwd_rollouts: Vec, /// 캐시된 파싱 결과. session: CodexSession, } /// 파일 mtime을 epoch ms로 반환한다(캐시 무효화 키). 실패 시 `None`. fn file_mtime_ms(path: &Path) -> Option { - let modified = path.metadata().and_then(|m| m.modified()).ok()?; + file_mtime_ms_checked(path).ok() +} + +fn file_mtime_ms_checked(path: &Path) -> std::io::Result { + let modified = path.metadata().and_then(|m| m.modified())?; modified .duration_since(UNIX_EPOCH) - .ok() .map(|d| d.as_millis()) + .map_err(|_| Error::new(ErrorKind::InvalidData, "mtime precedes unix epoch")) } /// 캐시 히트 시 해소된 파일의 mtime(epoch ms)이 여전히 freshness 이내인지 판정한다(spec §5/§8). @@ -638,16 +950,47 @@ fn is_mtime_fresh(mtime_ms: u128, now: SystemTime, freshness_secs: u64) -> bool elapsed_secs <= freshness_secs as u128 } +/// 캐시 생성 시 stale이라 제외됐던 동일 cwd rollout들이 여전히 stale인지 확인한다. +/// +/// 기존 stale 파일이 append 등으로 fresh가 되면 parent day directory mtime은 바뀌지 않을 수 있다. +/// 그 경우 캐시된 단일 후보를 그대로 쓰면 새 모호성을 놓치므로, 감시 대상 중 하나라도 fresh가 +/// 되면 캐시를 무시하고 풀 재해소로 떨어진다. 삭제는 더 이상 후보가 아니므로 안전하지만, +/// broken symlink/권한/race 등 stat 실패가 path 존재와 함께 관찰되면 캐시 재사용을 금지한다. +fn watched_rollouts_still_stale( + watched: &[WatchedRollout], + now: SystemTime, + freshness_secs: u64, +) -> bool { + watched.iter().all(|rollout| { + let path = PathBuf::from(&rollout.path); + match file_mtime_ms_checked(&path) { + Ok(mtime) => !is_mtime_fresh(mtime, now, freshness_secs), + Err(err) if err.kind() == ErrorKind::NotFound => { + std::fs::symlink_metadata(&path).is_err() + } + Err(_) => false, + } + }) +} + /// 세션 데이터를 디스크 캐시에서 조회/갱신해 [`CodexSession`]을 반환한다(spec §8 매 틱 로직). /// -/// 1) 캐시 히트 & 경로 mtime 불변 & **파일 mtime freshness 이내** → 재사용(스캔 0, stat 1회). +/// 1) 캐시 히트 & rollout 트리 지문 불변 & 경로 mtime 불변 & **파일 mtime freshness 이내** +/// → 재사용(후보 본문 재독해 없음). /// 2) 캐시 히트 & mtime 변동 & **파일 mtime freshness 이내** → 그 파일만 tail 재독 → 캐시 갱신. -/// 3) 미스/경로 stale/**파일 mtime stale**/없음 → 풀 후보스캔 재해소. **Ambiguous는 캐시하지 않는다**. +/// 3) 미스/트리 지문 변동/경로 stale/**파일 mtime stale**/없음 → 풀 후보스캔 재해소. +/// **Ambiguous는 캐시하지 않는다**. /// /// **파일 freshness 재검증(spec §5 일관성)**: 캐시 신선도는 기록 시각 기준이라, 캐시 히트 시 /// 해소된 파일의 mtime이 여전히 freshness 이내인지([`is_mtime_fresh`]) 추가 검증한다. stale이면 /// 캐시를 무시하고 (3) 풀 재해소로 떨어진다 — 종료된 세션은 freshness 경과 후 더는 표시되지 않는다. /// +/// **모호성 재검증**: 캐시된 파일 자체가 그대로여도 같은 cwd의 새 rollout이 생기면 이전 단일 +/// 후보 판단은 무효다. 최근 rollout 트리 지문이 바뀌면 캐시를 바로 쓰지 않고 후보 스캔을 다시 +/// 수행해 새 모호성을 fail-safe로 반영한다. 또한 캐시 생성 당시 stale이었던 동일 cwd rollout이 +/// 이후 fresh가 되면 day-dir mtime이 유지될 수 있으므로, 해당 watch-list를 stat해 fresh 전환을 +/// 감지하고 재스캔한다. +/// /// # 반환 /// 단일 해소 시 `Some(session)`. 모호/없음 시 `None`(무변경 신호). fn resolve_with_cache( @@ -661,6 +1004,8 @@ fn resolve_with_cache( ) -> Option { let now_ms = cache_now_millis(); let freshness_secs = freshness.saturating_mul(60); + let normalized_cwd = normalize_cwd_for_match(cwd); + let normalized_cwd_key = normalized_cwd.to_string_lossy().into_owned(); // 캐시 조회. if let Some((written_ms, payload)) = @@ -668,42 +1013,89 @@ fn resolve_with_cache( { if is_named_cache_fresh(written_ms, now_ms, freshness_secs) { if let Ok(entry) = serde_json::from_str::(&payload) { - let cached_path = PathBuf::from(&entry.path); - match file_mtime_ms(&cached_path) { - // 파일 mtime이 stale(freshness 경과)이면 캐시를 무시하고 풀 재해소로 폴백한다 - // (종료된 세션의 stale 표시 차단, find_codex_candidates 선필터와 일관). - Some(current_mtime) if !is_mtime_fresh(current_mtime, now, freshness_secs) => {} - // (1) 경로 mtime 불변 & fresh → 재사용(stat 1회). - Some(current_mtime) if current_mtime == entry.mtime_ms => { - return Some(entry.session); - } - // (2) mtime 변동(파일 존재) & fresh → 그 파일만 tail 재독 → 캐시 갱신. - Some(current_mtime) => { - if let Some(session) = extract_from_file(&cached_path) { - write_cache_entry( - cache_base, - session_key, - &cached_path, - current_mtime, - &session, - now_ms, - ); - return Some(session); + let cwd_matches_cache = entry.matched_cwd.as_deref() == Some(&normalized_cwd_key); + if cwd_matches_cache { + let current_fingerprint = current_scan_fingerprint(base, scan_days); + let fingerprint_stable = entry + .scan_fingerprint + .as_ref() + .zip(current_fingerprint.as_ref()) + .map(|(cached, current)| cached == current) + .unwrap_or(false); + let watched_still_stale = watched_rollouts_still_stale( + &entry.stale_same_cwd_rollouts, + now, + freshness_secs, + ); + if !fingerprint_stable || !watched_still_stale { + // cwd는 같지만 최근 일자 디렉터리가 바뀌었다. 새 rollout 후보가 생겼을 수 + // 있거나, 기존 stale 동일-cwd rollout이 fresh가 됐을 수 있으므로 캐시를 + // 바로 쓰지 않고 아래 풀 해소로 폴백한다. + } else { + let cached_path = PathBuf::from(&entry.path); + if !matches!(is_rollout_file(&cached_path), Ok(true)) { + // 캐시 경로가 더 이상 regular rollout 파일이 아니면 mtime이 같아도 + // cached session을 신뢰하지 않는다. 아래 풀 스캔으로 폴백한다. + } else { + match file_mtime_ms(&cached_path) { + // 파일 mtime이 stale(freshness 경과)이면 캐시를 무시하고 풀 재해소로 폴백한다 + // (종료된 세션의 stale 표시 차단, find_codex_candidates 선필터와 일관). + Some(current_mtime) + if !is_mtime_fresh(current_mtime, now, freshness_secs) => {} + // (1) 트리 지문/경로 mtime 불변 & fresh → 재사용(후보 본문 재독해 없음). + Some(current_mtime) if current_mtime == entry.mtime_ms => { + return Some(entry.session); + } + // (2) mtime 변동(파일 존재) & fresh → 그 파일만 tail 재독 → 캐시 갱신. + Some(current_mtime) => { + if let Some(session) = extract_from_file(&cached_path) { + write_cache_entry( + cache_base, + session_key, + CodexCacheEntry { + path: cached_path.to_string_lossy().into_owned(), + mtime_ms: current_mtime, + matched_cwd: Some(normalized_cwd_key.clone()), + scan_fingerprint: current_fingerprint.clone(), + stale_same_cwd_rollouts: entry + .stale_same_cwd_rollouts + .clone(), + session: session.clone(), + }, + now_ms, + ); + return Some(session); + } + } + // 경로 소실 → 풀 재해소로 폴백. + None => {} + } } } - // 경로 소실 → 풀 재해소로 폴백. - None => {} } } } } // (3) 미스/stale/경로 소실 → 풀 후보스캔 재해소. - match read_codex_session(base, cwd, now, freshness, scan_days) { + let scan = scan_codex_candidates(base, cwd, now, freshness, scan_days); + match resolution_from_scan(&scan) { Resolution::Single(session, path) => { // 단일 해소만 캐시한다(모호는 비캐시 — TTL 고착 차단). if let Some(mtime) = file_mtime_ms(&path) { - write_cache_entry(cache_base, session_key, &path, mtime, &session, now_ms); + write_cache_entry( + cache_base, + session_key, + CodexCacheEntry { + path: path.to_string_lossy().into_owned(), + mtime_ms: mtime, + matched_cwd: Some(normalized_cwd_key.clone()), + scan_fingerprint: Some(scan.fingerprint.clone()), + stale_same_cwd_rollouts: scan.stale_same_cwd_rollouts.clone(), + session: session.clone(), + }, + now_ms, + ); } Some(session) } @@ -716,19 +1108,7 @@ fn resolve_with_cache( /// /// `cache_base`는 캐시 루트(런타임은 `chain::cache_dir()`, 테스트는 주입 tempdir)로, /// process-global `HOME` 비의존 hermetic 기록을 위해 [`write_session_named_cache_in`]에 위임한다. -fn write_cache_entry( - cache_base: &Path, - session_key: &str, - path: &Path, - mtime_ms: u128, - session: &CodexSession, - now_ms: u128, -) { - let entry = CodexCacheEntry { - path: path.to_string_lossy().into_owned(), - mtime_ms, - session: session.clone(), - }; +fn write_cache_entry(cache_base: &Path, session_key: &str, entry: CodexCacheEntry, now_ms: u128) { if let Ok(payload) = serde_json::to_string(&entry) { write_session_named_cache_in(cache_base, session_key, CODEX_CACHE_FILE, now_ms, &payload); } @@ -1168,6 +1548,160 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + /// 회귀: 상한까지 읽어도 개행이 없는 첫 줄은 부분 JSON으로 파싱하지 않고 제외한다. + #[test] + fn find_candidates_rejects_unterminated_over_limit_first_line() { + let base = unique_tmp("bigmeta-no-newline"); + let cwd = "/Users/me/projBigMetaNoNewline"; + let day_dir = base.join("sessions").join("2026").join("06").join("05"); + std::fs::create_dir_all(&day_dir).unwrap(); + let path = day_dir.join("rollout-2026-06-05T20-40-45-bigmeta-no-newline.jsonl"); + let mut file = std::fs::File::create(&path).unwrap(); + write!( + file, + r#"{{"timestamp":"2026-06-05T11:41:50.379Z","type":"session_meta","payload":{{"id":"abc","cwd":"{cwd}","originator":"codex-tui","base_instructions":{{"text":""# + ) + .unwrap(); + write!(file, "{}", "A".repeat(FIRST_LINE_READ_BYTES as usize)).unwrap(); + file.flush().unwrap(); + + assert!( + read_first_line_meta(&path).is_none(), + "개행 없이 상한에 걸린 첫 줄은 부분 session_meta로 파싱하면 안 됨" + ); + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.scan_incomplete, + "미완결 과대 첫 줄은 후보 없음이 아니라 불완전 스캔으로 전파되어야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "미완결 과대 첫 줄은 실제 해소 경로에서 fail-safe" + ); + let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert_eq!( + found.len(), + 0, + "미완결 과대 첫 줄은 cwd/originator 후보에서 제외" + ); + let _ = std::fs::remove_dir_all(&base); + } + + /// 회귀: 짧은 첫 줄도 개행으로 완결되지 않았으면 부분 라인으로 보고 fail-safe 처리한다. + #[test] + fn short_unterminated_first_line_fails_safe() { + let base = unique_tmp("short-no-newline"); + let cwd = "/Users/me/projShortNoNewline"; + write_rollout( + &base, + "short-valid", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + + let day_dir = base.join("sessions").join("2026").join("06").join("05"); + let partial_path = day_dir.join("rollout-2026-06-05T20-40-45-short-partial.jsonl"); + let mut partial = std::fs::File::create(&partial_path).unwrap(); + write!(partial, "{}", session_meta_line(cwd, "codex-tui")).unwrap(); + partial.flush().unwrap(); + + assert!( + read_first_line_meta(&partial_path).is_none(), + "짧더라도 개행 없는 첫 줄은 legacy API에서 후보 없음으로 보임" + ); + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.scan_incomplete, + "짧은 미완결 첫 줄도 scan_incomplete로 전파되어야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "다른 valid 후보가 있어도 부분 라인 rollout이 있으면 fail-safe" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// 회귀: 개행은 있어도 session_meta로 파싱되지 않는 첫 줄은 후보 없음이 아니라 불완전 스캔이다. + #[test] + fn malformed_first_line_fails_safe() { + let base = unique_tmp("malformed-first-line"); + let cwd = "/Users/me/projMalformedFirstLine"; + write_rollout( + &base, + "malformed-valid", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + + let day_dir = base.join("sessions").join("2026").join("06").join("05"); + let malformed_path = day_dir.join("rollout-2026-06-05T20-40-45-malformed.jsonl"); + let mut malformed = std::fs::File::create(&malformed_path).unwrap(); + writeln!(malformed, "{{not json").unwrap(); + malformed.flush().unwrap(); + + assert!( + read_first_line_meta(&malformed_path).is_none(), + "legacy parser API는 malformed first line을 None으로 노출" + ); + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.scan_incomplete, + "malformed first line은 scan_incomplete로 전파되어야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "다른 valid 후보가 있어도 malformed rollout이 있으면 fail-safe" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// rollout 이름 엔트리의 metadata를 확인할 수 없으면 단일 후보를 표시하지 않는다. + #[cfg(unix)] + #[test] + fn rollout_metadata_error_fails_safe() { + let base = unique_tmp("rollout-metadata-error"); + let cwd = "/Users/me/projRolloutMetadataError"; + let day_dir = base.join("sessions").join("2026").join("06").join("05"); + std::fs::create_dir_all(&day_dir).unwrap(); + + let candidate_path = day_dir.join("rollout-2026-06-05T20-40-45-candidate.jsonl"); + let mut candidate = std::fs::File::create(&candidate_path).unwrap(); + for line in [ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ] { + writeln!(candidate, "{line}").unwrap(); + } + + let broken = day_dir.join("rollout-2026-06-05T20-40-45-broken.jsonl"); + std::os::unix::fs::symlink(day_dir.join("missing-target.jsonl"), &broken).unwrap(); + + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.scan_incomplete, + "rollout-*.jsonl metadata 실패는 scan_incomplete로 전파되어야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "metadata 실패가 있는 부분 스캔에서 본 단일 후보를 표시하면 안 됨" + ); + + let _ = std::fs::remove_dir_all(&base); + } + /// AC-X1: 동일 cwd·fresh 후보 2개 → Ambiguous → enrich 생략(ctx/rate 미표시). #[test] fn ambiguous_two_same_cwd_candidates() { @@ -1240,52 +1774,247 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } - /// AC-X5: codex_exec(비대화형 originator)는 제외된다. + /// 스캔 파일 총량 상한을 넘으면 부분 결과를 단일 후보로 신뢰하지 않고 fail-safe로 저하한다. #[test] - fn exec_originator_excluded() { - let base = unique_tmp("exec"); - let cwd = "/Users/me/projExec"; - write_rollout( - &base, - "exec", - &[ - session_meta_line(cwd, "codex_exec"), - turn_context_line("gpt-5.5", "high"), - ], + fn scan_budget_exceeded_fails_safe() { + let base = unique_tmp("scan-budget"); + let cwd = "/Users/me/projBudget"; + let day_dir = base.join("sessions").join("2026").join("06").join("05"); + std::fs::create_dir_all(&day_dir).unwrap(); + + for idx in 0..=MAX_CODEX_SCAN_ENTRIES { + let path = day_dir.join(format!("rollout-2026-06-05T20-40-45-{idx:04}.jsonl")); + std::fs::File::create(path).unwrap(); + } + + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.budget_exceeded, + "디렉터리 엔트리가 상한을 넘으면 budget_exceeded가 기록되어야 함" ); - let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); - assert_eq!(found.len(), 0, "exec originator는 제외"); + let resolution = read_codex_session(&base, cwd, SystemTime::now(), 240, 3); + assert_eq!( + resolution, + Resolution::Ambiguous, + "부분 스캔으로 단일/없음을 확정하지 않고 enrich 생략 경로로 저하" + ); + let _ = std::fs::remove_dir_all(&base); } - /// cwd 정규화: trailing slash가 달라도 매칭된다(존재하는 임시 디렉터리로 canonicalize 경로). + /// 예산 초과 전에 단일 후보를 찾았더라도 전체 유일성을 증명할 수 없으면 fail-safe다. #[test] - fn cwd_normalization_trailing_slash_matches() { - let base = unique_tmp("cwdnorm"); - // 실제 존재하는 cwd 디렉터리를 만들어 canonicalize 경로로도 일치하게 한다. - let real_cwd = base.join("realcwd"); - std::fs::create_dir_all(&real_cwd).unwrap(); - let cwd_str = real_cwd.to_string_lossy().into_owned(); - write_rollout( - &base, - "cwdnorm", - &[ - session_meta_line(&cwd_str, "codex-tui"), - token_count_line(100, 1000, 0, 3.0, 21.0, "pro"), - ], + fn scan_budget_with_prior_candidate_still_fails_safe() { + let base = unique_tmp("scan-budget-prior-candidate"); + let cache_base = unique_tmp("scan-budget-prior-candidate-cache"); + let cwd = "/Users/me/projBudgetPriorCandidate"; + let newest_day = base.join("sessions").join("2026").join("06").join("06"); + let older_day = base.join("sessions").join("2026").join("06").join("05"); + std::fs::create_dir_all(&newest_day).unwrap(); + std::fs::create_dir_all(&older_day).unwrap(); + + let candidate_path = newest_day.join("rollout-2026-06-06T20-40-45-candidate.jsonl"); + let mut candidate = std::fs::File::create(&candidate_path).unwrap(); + for line in [ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ] { + writeln!(candidate, "{line}").unwrap(); + } + + for idx in 0..MAX_CODEX_SCAN_ENTRIES { + let path = older_day.join(format!("noise-{idx:04}.txt")); + std::fs::File::create(path).unwrap(); + } + + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.budget_exceeded, + "후보를 먼저 발견했더라도 뒤따른 디렉터리 엔트리 폭증은 budget_exceeded여야 함" ); - // target에 trailing slash를 붙여도 매칭되어야 한다. - let target = format!("{cwd_str}/"); - let found = find_codex_candidates(&base, &target, SystemTime::now(), 240, 3); - assert_eq!(found.len(), 1, "trailing slash 정규화 매칭"); + assert!( + scan.candidates.is_empty(), + "예산 초과 시에는 이미 존재하는 valid 후보도 신뢰 가능한 단일 후보로 노출하지 않음" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "부분 스캔에서 본 단일 후보를 표시하지 않고 Ambiguous로 저하" + ); + + let mut input = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some("scan-budget-prior-candidate-key".to_string()), + ..Default::default() + }; + let before = input.clone(); + maybe_enrich_in( + &mut input, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + input, before, + "budget_exceeded 상태에서는 이미 발견한 단일 후보로도 enrich하지 않아야 함" + ); + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); } - /// extract_from_file: head(baseline) + tail(최신) 결합. tail의 더 최신 turn_context/token_count 우선. + /// non-rollout 엔트리도 총 스캔 예산에 포함해 hot path 디렉터리 순회를 제한한다. #[test] - fn extract_combines_head_and_tail() { - let base = unique_tmp("extract"); - let cwd = "/Users/me/projExtract"; + fn scan_budget_counts_non_rollout_entries() { + let base = unique_tmp("scan-budget-noise"); + let cwd = "/Users/me/projBudgetNoise"; + let day_dir = base.join("sessions").join("2026").join("06").join("05"); + std::fs::create_dir_all(&day_dir).unwrap(); + + for idx in 0..=MAX_CODEX_SCAN_ENTRIES { + let path = day_dir.join(format!("noise-{idx:04}.txt")); + std::fs::File::create(path).unwrap(); + } + + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.budget_exceeded, + "non-rollout 파일이 많아도 총 디렉터리 엔트리 예산을 초과해야 함" + ); + assert_eq!(scan.candidates.len(), 0); + let resolution = read_codex_session(&base, cwd, SystemTime::now(), 240, 3); + assert_eq!( + resolution, + Resolution::Ambiguous, + "non-rollout 엔트리 폭증도 부분 스캔이므로 fail-safe" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// 정확히 예산만큼의 엔트리는 overflow가 아니다(경계 회귀). + #[test] + fn scan_budget_exact_limit_is_allowed() { + let base = unique_tmp("scan-budget-exact"); + let cwd = "/Users/me/projBudgetExact"; + let day_dir = base.join("sessions").join("2026").join("06").join("05"); + std::fs::create_dir_all(&day_dir).unwrap(); + + for idx in 0..MAX_CODEX_SCAN_ENTRIES { + let path = day_dir.join(format!("noise-{idx:04}.txt")); + std::fs::File::create(path).unwrap(); + } + + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + !scan.budget_exceeded, + "정확히 상한만큼의 엔트리는 budget_exceeded가 아니어야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::None + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// year/month/day discovery 자체도 예산 초과 시 단일 후보를 확정하지 않는다. + #[test] + fn date_discovery_budget_exceeded_fails_safe() { + let base = unique_tmp("date-budget"); + let cwd = "/Users/me/projDateBudget"; + let sessions_dir = base.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + + for idx in 0..=MAX_CODEX_DATE_DIR_ENTRIES { + std::fs::create_dir_all(sessions_dir.join(format!("{idx:04}"))).unwrap(); + } + + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.budget_exceeded, + "date discovery 엔트리 상한을 넘으면 budget_exceeded가 기록되어야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "date discovery 부분 스캔도 fail-safe로 저하" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// date discovery에서 `read_dir`가 실패하면 부분 view를 신뢰하지 않고 fail-safe로 저하한다. + #[test] + fn date_discovery_read_error_fails_safe() { + let base = unique_tmp("date-read-error"); + let cwd = "/Users/me/projDateReadError"; + let sessions_path = base.join("sessions"); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(&sessions_path, b"not a directory").unwrap(); + + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.scan_incomplete, + "date discovery read_dir 실패는 scan_incomplete로 기록되어야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "부분/불완전 스캔은 후보 없음으로 오판하지 않고 fail-safe" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + /// AC-X5: codex_exec(비대화형 originator)는 제외된다. + #[test] + fn exec_originator_excluded() { + let base = unique_tmp("exec"); + let cwd = "/Users/me/projExec"; + write_rollout( + &base, + "exec", + &[ + session_meta_line(cwd, "codex_exec"), + turn_context_line("gpt-5.5", "high"), + ], + ); + let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert_eq!(found.len(), 0, "exec originator는 제외"); + let _ = std::fs::remove_dir_all(&base); + } + + /// cwd 정규화: trailing slash가 달라도 매칭된다(존재하는 임시 디렉터리로 canonicalize 경로). + #[test] + fn cwd_normalization_trailing_slash_matches() { + let base = unique_tmp("cwdnorm"); + // 실제 존재하는 cwd 디렉터리를 만들어 canonicalize 경로로도 일치하게 한다. + let real_cwd = base.join("realcwd"); + std::fs::create_dir_all(&real_cwd).unwrap(); + let cwd_str = real_cwd.to_string_lossy().into_owned(); + write_rollout( + &base, + "cwdnorm", + &[ + session_meta_line(&cwd_str, "codex-tui"), + token_count_line(100, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + // target에 trailing slash를 붙여도 매칭되어야 한다. + let target = format!("{cwd_str}/"); + let found = find_codex_candidates(&base, &target, SystemTime::now(), 240, 3); + assert_eq!(found.len(), 1, "trailing slash 정규화 매칭"); + let _ = std::fs::remove_dir_all(&base); + } + + /// extract_from_file: head(baseline) + tail(최신) 결합. tail의 더 최신 turn_context/token_count 우선. + #[test] + fn extract_combines_head_and_tail() { + let base = unique_tmp("extract"); + let cwd = "/Users/me/projExtract"; let path = write_rollout( &base, "extract", @@ -1488,10 +2217,11 @@ mod tests { let _ = std::fs::remove_dir_all(&cache_base); } - /// AC-X6: 캐시 정상상태 — 2회차는 경로 mtime 불변이면 재해소 없이 캐시를 재사용한다. + /// AC-X6: 캐시 정상상태 — 2회차는 같은 cwd와 경로 mtime 불변이면 후보 본문 재독해 없이 + /// 캐시를 재사용한다. /// - /// 1회차에 캐시를 채운 뒤, 2회차는 매칭 불가 cwd로 호출한다. 캐시 재사용이면 같은 키로 - /// 캐시 히트 → 경로 mtime 불변 → 재사용되어 여전히 enrich가 성공해야 한다(풀 재해소면 실패). + /// 1회차에 캐시를 채운 뒤, 2회차도 같은 cwd/key로 호출한다. 캐시 재사용이면 경로 mtime + /// 불변 → 재사용되어 여전히 enrich가 성공해야 한다. #[test] fn cache_steady_state_reuses_without_rescan() { let base = unique_tmp("cache-steady"); @@ -1523,11 +2253,10 @@ mod tests { ); assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); - // 2회차: 같은 캐시 키지만 매칭 불가 cwd. 풀스캔이면 0 발견이지만 캐시 히트 → - // 경로 mtime 불변 → 재사용되어 여전히 성공해야 한다(정상상태 stat 1회). + // 2회차: 같은 cwd/key. 최근 일자 디렉터리 mtime과 경로 mtime 불변 → 캐시 재사용. let mut second = ClaudeInput { model_display_name: Some("codex".to_string()), - cwd: Some("/no/match/here".to_string()), + cwd: Some(cwd.to_string()), session_id: Some(key.to_string()), ..Default::default() }; @@ -1540,18 +2269,489 @@ mod tests { assert_eq!( second.model_display_name.as_deref(), Some("gpt-5.5"), - "정상상태는 캐시 재사용(재스캔 없이 stat 1회)" + "정상상태는 캐시 재사용" ); // cache_base 격리로 캐시는 temp 하위에만 존재하므로 실캐시 청소가 불필요하다. let _ = std::fs::remove_dir_all(&base); let _ = std::fs::remove_dir_all(&cache_base); } + /// 같은 cache key라도 현재 cwd가 달라지면 cached session을 재사용하지 않는다. + #[test] + fn cache_hit_with_different_cwd_does_not_reuse() { + let base = unique_tmp("cache-cwd"); + let cache_base = unique_tmp("cache-cwd-cache"); + let cwd = "/Users/me/projCacheCwd"; + let key = "cache-cwd-key"; + write_rollout( + &base, + "cachecwd", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + + let mut first = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + maybe_enrich_in( + &mut first, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); + + let mut second = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some("/no/match/here".to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + let before = second.clone(); + maybe_enrich_in( + &mut second, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + second, before, + "동일 key라도 cwd가 다르면 cached Codex session을 표시하면 안 됨" + ); + + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); + } + + /// 캐시 히트라도 같은 cwd의 새 fresh rollout이 생기면 이전 단일 후보를 재사용하지 않는다. + /// + /// 1회차 단일 후보로 캐시를 채운 뒤 2회차 전에 같은 cwd 후보를 추가한다. 캐시가 rollout + /// 트리 지문 변화를 무시하면 stale 단일 세션을 계속 표시하지만, 올바른 동작은 재스캔 후 + /// Ambiguous로 안전 저하되어 input을 그대로 두는 것이다. + #[test] + fn cache_hit_rechecks_new_same_cwd_rollout_before_reuse() { + let base = unique_tmp("cache-amb"); + let cache_base = unique_tmp("cache-amb-cache"); + let cwd = "/Users/me/projCacheAmb"; + let key = "cache-amb-key"; + write_rollout( + &base, + "cacheamb1", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + + let mut first = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + maybe_enrich_in( + &mut first, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); + + let second_rollout = write_rollout( + &base, + "cacheamb2", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.4-mini", "medium"), + token_count_line(100, 1000, 0, 2.0, 10.0, "pro"), + ], + ); + if let Some(day_dir) = second_rollout.parent() { + set_file_mtime(day_dir, SystemTime::now() + Duration::from_secs(1)); + } + + let mut second = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + let before = second.clone(); + maybe_enrich_in( + &mut second, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + second, before, + "새 같은-cwd 후보가 생긴 뒤에는 캐시 재사용 대신 Ambiguous로 안전 저하" + ); + + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); + } + + /// 기존 stale 동일-cwd rollout이 append 등으로 fresh가 되면 day-dir mtime이 그대로여도 + /// 캐시를 재사용하지 않고 재스캔해 Ambiguous로 안전 저하한다. + #[test] + fn cache_hit_rechecks_stale_same_cwd_rollout_becoming_fresh() { + let base = unique_tmp("cache-stale-watch"); + let cache_base = unique_tmp("cache-stale-watch-cache"); + let cwd = "/Users/me/projCacheStaleWatch"; + let key = "cache-stale-watch-key"; + let fresh_path = write_rollout( + &base, + "fresh", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + let stale_path = write_rollout( + &base, + "stale-watch", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.4-mini", "medium"), + token_count_line(100, 1000, 0, 2.0, 10.0, "pro"), + ], + ); + let day_dir = fresh_path.parent().expect("day dir").to_path_buf(); + let stable_day_mtime = SystemTime::now() - Duration::from_secs(30); + let stale_time = SystemTime::now() - Duration::from_secs(5 * 3600); + set_file_mtime(&stale_path, stale_time); + set_file_mtime(&day_dir, stable_day_mtime); + + let mut first = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + maybe_enrich_in( + &mut first, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); + + // 기존 stale 파일만 fresh로 바꾼다. parent day-dir mtime은 원래 지문과 같게 되돌려 + // day-dir fingerprint만으로는 변화를 볼 수 없는 케이스를 박제한다. + set_file_mtime(&stale_path, SystemTime::now() + Duration::from_secs(1)); + set_file_mtime(&day_dir, stable_day_mtime); + + let mut second = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + let before = second.clone(); + maybe_enrich_in( + &mut second, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + second, before, + "기존 stale 동일-cwd rollout이 fresh가 되면 cached 단일 세션 대신 Ambiguous로 저하" + ); + + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); + } + + /// watched stale rollout의 stat 실패는 "아직 stale"이 아니라 cache reuse 금지로 처리한다. + #[cfg(unix)] + #[test] + fn cache_hit_rejects_watched_rollout_stat_failure() { + let base = unique_tmp("cache-watch-stat-failure"); + let cache_base = unique_tmp("cache-watch-stat-failure-cache"); + let cwd = "/Users/me/projCacheWatchStatFailure"; + let key = "cache-watch-stat-failure-key"; + let fresh_path = write_rollout( + &base, + "watchfresh", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + let stale_path = write_rollout( + &base, + "watchbroken", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.4-mini", "medium"), + token_count_line(100, 1000, 0, 2.0, 10.0, "pro"), + ], + ); + let day_dir = fresh_path.parent().expect("day dir").to_path_buf(); + let stable_day_mtime = SystemTime::now() - Duration::from_secs(30); + set_file_mtime( + &stale_path, + SystemTime::now() - Duration::from_secs(5 * 3600), + ); + set_file_mtime(&day_dir, stable_day_mtime); + + let mut first = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + maybe_enrich_in( + &mut first, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); + + std::fs::remove_file(&stale_path).unwrap(); + std::os::unix::fs::symlink(day_dir.join("missing-watch-target.jsonl"), &stale_path) + .unwrap(); + set_file_mtime(&day_dir, stable_day_mtime); + + let mut second = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + let before = second.clone(); + maybe_enrich_in( + &mut second, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + second, before, + "watched rollout stat 실패는 cached 단일 세션 재사용 대신 fail-safe로 저하" + ); + + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); + } + + /// day directory mtime을 증명할 수 없으면 cache fingerprint도, full scan도 fail-safe 처리한다. + #[cfg(unix)] + #[test] + fn day_dir_mtime_error_invalidates_cache_and_scan() { + let base = unique_tmp("cache-day-mtime-error"); + let cache_base = unique_tmp("cache-day-mtime-error-cache"); + let cwd = "/Users/me/projCacheDayMtimeError"; + let key = "cache-day-mtime-error-key"; + let rollout_path = write_rollout( + &base, + "daymtime", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + let day_dir = rollout_path.parent().expect("day dir").to_path_buf(); + + let mut first = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + maybe_enrich_in( + &mut first, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); + + set_file_mtime_before_epoch(&day_dir); + assert!( + current_scan_fingerprint(&base, 3).is_none(), + "day-dir mtime 불확실성은 cache fingerprint 재사용을 금지해야 함" + ); + let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3); + assert!( + scan.scan_incomplete, + "day-dir mtime 불확실성은 full scan에서도 scan_incomplete여야 함" + ); + assert_eq!( + read_codex_session(&base, cwd, SystemTime::now(), 240, 3), + Resolution::Ambiguous, + "mtime 불확실성이 있으면 valid 후보가 있어도 fail-safe" + ); + + let mut second = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + let before = second.clone(); + maybe_enrich_in( + &mut second, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + second, before, + "day-dir mtime 불확실성은 cached 단일 세션 재사용 대신 fail-safe" + ); + + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); + } + + /// 캐시된 rollout 경로가 더 이상 regular 파일이 아니면 동일 mtime이어도 재사용하지 않는다. + #[test] + fn cache_hit_rejects_cached_path_file_type_change() { + let base = unique_tmp("cache-file-type"); + let cache_base = unique_tmp("cache-file-type-cache"); + let cwd = "/Users/me/projCacheFileType"; + let key = "cache-file-type-key"; + let rollout_path = write_rollout( + &base, + "filetype", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + let day_dir = rollout_path.parent().expect("day dir").to_path_buf(); + let stable_rollout_mtime = SystemTime::now() - Duration::from_secs(30); + let stable_day_mtime = SystemTime::now() - Duration::from_secs(20); + set_file_mtime(&rollout_path, stable_rollout_mtime); + set_file_mtime(&day_dir, stable_day_mtime); + + let mut first = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + maybe_enrich_in( + &mut first, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); + + std::fs::remove_file(&rollout_path).unwrap(); + std::fs::create_dir(&rollout_path).unwrap(); + set_file_mtime(&rollout_path, stable_rollout_mtime); + set_file_mtime(&day_dir, stable_day_mtime); + + let mut second = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + let before = second.clone(); + maybe_enrich_in( + &mut second, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + second, before, + "cached path가 directory로 바뀌면 mtime이 같아도 cached Codex session을 재사용하면 안 됨" + ); + + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); + } + + /// 캐시 히트에서도 현재 scan budget을 재검증해 부분 visibility에서 재사용하지 않는다. + #[test] + fn cache_hit_rechecks_scan_budget_even_when_day_mtime_stable() { + let base = unique_tmp("cache-hit-budget"); + let cache_base = unique_tmp("cache-hit-budget-cache"); + let cwd = "/Users/me/projCacheHitBudget"; + let key = "cache-hit-budget-key"; + let rollout_path = write_rollout( + &base, + "hitbudget", + &[ + session_meta_line(cwd, "codex-tui"), + turn_context_line("gpt-5.5", "high"), + token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), + ], + ); + let day_dir = rollout_path.parent().expect("day dir").to_path_buf(); + let stable_day_mtime = SystemTime::now() - Duration::from_secs(20); + set_file_mtime(&day_dir, stable_day_mtime); + + let mut first = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + maybe_enrich_in( + &mut first, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5")); + + for idx in 0..MAX_CODEX_SCAN_ENTRIES { + let path = day_dir.join(format!("noise-cache-hit-{idx:04}.txt")); + std::fs::File::create(path).unwrap(); + } + set_file_mtime(&day_dir, stable_day_mtime); + assert!( + current_scan_fingerprint(&base, 3).is_none(), + "cache-hit fingerprint probe must fail closed when current scan budget is exceeded" + ); + + let mut second = ClaudeInput { + model_display_name: Some("codex".to_string()), + cwd: Some(cwd.to_string()), + session_id: Some(key.to_string()), + ..Default::default() + }; + let before = second.clone(); + maybe_enrich_in( + &mut second, + &Config::default(), + Some(&base), + Some(&cache_base), + ); + assert_eq!( + second, before, + "day mtime이 안정적이어도 current scan budget 초과 시 cached 단일 세션을 재사용하면 안 됨" + ); + + let _ = std::fs::remove_dir_all(&base); + let _ = std::fs::remove_dir_all(&cache_base); + } + /// M2: 캐시 히트라도 해소된 파일 mtime이 freshness를 넘기면 캐시를 무시하고 재해소한다. /// /// 캐시 신선도는 기록 시각 기준이라, 세션 종료 후 파일 mtime이 고정돼도 마지막 캐시 write로부터 /// freshness 동안 stale 세션을 계속 표시하는 결함을 박제한다(spec §5 "fresh 후보만" 일관성). - /// 1회차로 캐시를 채운 뒤 해소된 파일 mtime을 freshness보다 오래되게 만들고, 2회차는 매칭 불가 + /// 1회차로 캐시를 채운 뒤 해소된 파일 mtime을 freshness보다 오래되게 만들고, 2회차는 같은 /// cwd로 호출한다. 캐시가 stale로 무시되면 풀 재해소가 0 후보 → None(model="codex" 유지)이어야 한다. /// (이전 동작은 stale 캐시를 재사용해 enrich를 유지했으므로 이 단언이 회귀 가드 역할을 한다.) #[test] @@ -1593,11 +2793,11 @@ mod tests { let five_hours_ago = SystemTime::now() - Duration::from_secs(5 * 3600); set_file_mtime(&rollout_path, five_hours_ago); - // 2회차: 같은 캐시 키지만 매칭 불가 cwd. 캐시 히트하더라도 파일 mtime이 stale이므로 + // 2회차: 같은 캐시 키/cwd. 캐시 히트하더라도 파일 mtime이 stale이므로 // 캐시를 무시하고 풀 재해소 → 0 후보 → None(무변경, model="codex" 유지)이어야 한다. let mut second = ClaudeInput { model_display_name: Some("codex".to_string()), - cwd: Some("/no/match/here".to_string()), + cwd: Some(cwd.to_string()), session_id: Some(key.to_string()), ..Default::default() }; @@ -1645,4 +2845,23 @@ mod tests { libc::utimes(c_path.as_ptr(), times.as_ptr()); } } + + /// 파일/디렉터리 mtime을 UNIX_EPOCH 이전으로 설정해 `file_mtime_ms_checked` 실패를 재현한다. + #[cfg(unix)] + fn set_file_mtime_before_epoch(path: &Path) { + let times = [ + libc::timeval { + tv_sec: -1, + tv_usec: 0, + }, + libc::timeval { + tv_sec: -1, + tv_usec: 0, + }, + ]; + let c_path = std::ffi::CString::new(path.to_string_lossy().as_bytes()).unwrap(); + // SAFETY: 유효한 경로/timeval 포인터로 utimes 호출한다. 테스트 픽스처이므로 실패 시 즉시 실패. + let rc = unsafe { libc::utimes(c_path.as_ptr(), times.as_ptr()) }; + assert_eq!(rc, 0, "pre-epoch mtime 설정 실패"); + } } diff --git a/src/config.rs b/src/config.rs index 635bb10..aa3c492 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,9 +4,16 @@ //! 전 항목 기본값으로 안전 저하하며(깨진 TOML은 stderr 경고), 절대 패닉하지 않는다. use serde::Deserialize; +use std::io::Read; use crate::themes; +/// 렌더 핫패스에서 허용하는 config.toml 최대 크기. +/// +/// 실제 설정은 수 KiB 수준이다. 비정상적으로 큰 파일이 매 statusline 렌더마다 무제한 read/parse 비용을 +/// 유발하지 않도록 256KiB에서 기본값으로 안전 저하한다. +const MAX_CONFIG_BYTES: usize = 256 * 1024; + /// understatus 전체 설정. 각 섹션은 §H-8 TOML의 테이블에 1:1 대응한다. /// /// `#[serde(default)]`로 부분 설정/누락 섹션을 안전하게 기본값으로 채운다. @@ -367,8 +374,16 @@ pub fn load_config() -> Config { None => return Config::default(), }; + load_config_from_path(&path) +} + +/// 주어진 경로의 config를 크기 제한 안에서 로드한다. +/// +/// 파일 부재/읽기 실패/UTF-8 오류/크기 초과는 모두 [`Config::default`]로 안전 저하한다. TOML 파싱 +/// 실패만 기존처럼 [`parse_config_str`]에서 stderr 경고를 출력한다. +fn load_config_from_path(path: &std::path::Path) -> Config { // 파일 부재 → 조용히 기본값(경고 없음, AC7). - let contents = match std::fs::read_to_string(&path) { + let contents = match read_config_file_limited(path, MAX_CONFIG_BYTES) { Ok(contents) => contents, Err(_) => return Config::default(), }; @@ -376,6 +391,24 @@ pub fn load_config() -> Config { parse_config_str(&contents) } +/// `path`를 `max_bytes` 이하로만 읽어 UTF-8 문자열로 반환한다. +/// +/// `max_bytes + 1`까지만 읽어서 초과 여부를 확인하므로, 큰 설정 파일도 bounded work로 끝난다. +fn read_config_file_limited(path: &std::path::Path, max_bytes: usize) -> std::io::Result { + let file = std::fs::File::open(path)?; + let mut bytes = Vec::new(); + let limit = (max_bytes as u64).saturating_add(1); + file.take(limit).read_to_end(&mut bytes)?; + if bytes.len() > max_bytes { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "config.toml exceeds hot-path size limit", + )); + } + String::from_utf8(bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) +} + /// 설정 파일 경로를 결정한다. /// /// # 반환 @@ -614,6 +647,65 @@ mod tests { assert_eq!(config.pulse.pulse_on_threshold, 90.0); } + /// 테스트별 임시 config 경로를 만든다(프로세스 pid + 라벨로 충돌 회피). + fn temp_config_path(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("understatus-config-{label}-{}", std::process::id())) + } + + /// config 파일 제한 헬퍼는 한도 이하 UTF-8 설정을 그대로 읽어야 한다. + #[test] + fn read_config_file_limited_accepts_within_limit() { + let path = temp_config_path("within-limit"); + std::fs::write(&path, "[display]\nmax_width = 120\n").expect("임시 config 작성"); + + let contents = read_config_file_limited(&path, MAX_CONFIG_BYTES).expect("읽기 성공"); + assert!(contents.contains("max_width = 120")); + + let _ = std::fs::remove_file(path); + } + + /// 정확히 한도 크기인 config는 허용한다(off-by-one 회귀 방지). + #[test] + fn read_config_file_limited_allows_exact_limit() { + let path = temp_config_path("exact-limit"); + std::fs::write(&path, "abcd").expect("임시 config 작성"); + + assert_eq!(read_config_file_limited(&path, 4).unwrap(), "abcd"); + + let _ = std::fs::remove_file(path); + } + + /// 한도를 초과한 config는 파싱하지 않고 기본값으로 안전 저하한다. + #[test] + fn oversized_config_falls_back_to_default() { + let path = temp_config_path("oversize"); + let mut contents = String::from("theme = \"vivid\"\n#"); + contents.push_str(&"x".repeat(MAX_CONFIG_BYTES + 1)); + std::fs::write(&path, contents).expect("임시 config 작성"); + + assert!(read_config_file_limited(&path, 8).is_err()); + let config = load_config_from_path(&path); + assert_eq!( + config.theme, "calm", + "MAX_CONFIG_BYTES 초과 파일은 사용자 테마를 파싱하지 않고 기본값으로 저하" + ); + + let _ = std::fs::remove_file(path); + } + + /// 손상 UTF-8 config도 TOML 파서까지 넘기지 않고 기본값으로 저하한다. + #[test] + fn invalid_utf8_config_falls_back_to_default() { + let path = temp_config_path("invalid-utf8"); + std::fs::write(&path, [0xff, 0xfe, 0xfd]).expect("임시 config 작성"); + + assert!(read_config_file_limited(&path, MAX_CONFIG_BYTES).is_err()); + let config = load_config_from_path(&path); + assert_eq!(config.theme, "calm"); + + let _ = std::fs::remove_file(path); + } + /// chain_command의 `$HOME`이 실제 홈 경로로 확장되어야 한다. #[test] fn expands_home_var_in_chain_command() { diff --git a/src/main.rs b/src/main.rs index e1be938..d96d89e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,12 @@ mod themes; use std::io::{BufRead, IsTerminal, Read, Write}; use std::process::ExitCode; +/// 렌더 핫패스에서 허용하는 stdin 최대 크기. +/// +/// Claude/lterm/codex statusline payload는 보통 한 줄의 작은 JSON이다. 파이프가 비정상적으로 큰 입력을 +/// 밀어 넣어도 렌더가 무제한 메모리와 chain 전달 비용을 쓰지 않도록 1MiB에서 끊고 빈 입력으로 안전 저하한다. +const MAX_RENDER_STDIN_BYTES: usize = 1024 * 1024; + /// 진입점: 서브커맨드를 디스패치한다. /// /// # 반환 @@ -794,9 +800,25 @@ fn run_render_pipeline(source: Source, oneline: bool, surface_format: SurfaceFor /// # 반환 /// stdin 전체 내용. 읽기 실패 시 빈 문자열로 안전 저하한다(파이프라인은 빈 입력에도 무패닉, AC1). fn read_stdin() -> String { - let mut buffer = String::new(); - let _ = std::io::stdin().read_to_string(&mut buffer); - buffer + let stdin = std::io::stdin(); + let mut reader = stdin.lock(); + read_to_string_limited(&mut reader, MAX_RENDER_STDIN_BYTES) +} + +/// `max_bytes`를 초과하지 않는 범위에서 UTF-8 입력을 읽는다. +/// +/// 초과/읽기 실패/UTF-8 오류는 모두 빈 문자열로 안전 저하한다. 호출부는 빈 입력도 무패닉으로 처리하므로 +/// 악의적·손상 stdin이 렌더 핫패스를 장시간 점유하거나 chain 자식으로 그대로 전파되지 않는다. +fn read_to_string_limited(reader: &mut R, max_bytes: usize) -> String { + let mut bytes = Vec::new(); + let limit = (max_bytes as u64).saturating_add(1); + if reader.take(limit).read_to_end(&mut bytes).is_err() { + return String::new(); + } + if bytes.len() > max_bytes { + return String::new(); + } + String::from_utf8(bytes).unwrap_or_default() } /// 현재 시각을 UNIX epoch 기준 밀리초(ms)로 반환한다. @@ -851,6 +873,37 @@ mod tests { use super::*; use std::io::Cursor; + /// 렌더 stdin 제한 헬퍼는 한도 이하 정상 JSON을 바이트 손실 없이 보존해야 한다. + #[test] + fn read_to_string_limited_preserves_payload_within_limit() { + let mut reader = Cursor::new(br#"{"session_id":"ok"}"#.to_vec()); + assert_eq!( + read_to_string_limited(&mut reader, MAX_RENDER_STDIN_BYTES), + r#"{"session_id":"ok"}"# + ); + } + + /// 정확히 한도 크기인 입력은 허용한다(초과 판정 off-by-one 방지). + #[test] + fn read_to_string_limited_allows_exact_limit() { + let mut reader = Cursor::new(b"abcd".to_vec()); + assert_eq!(read_to_string_limited(&mut reader, 4), "abcd"); + } + + /// 한도를 1바이트라도 넘으면 부분 JSON을 파싱하지 않고 빈 입력으로 안전 저하한다. + #[test] + fn read_to_string_limited_rejects_oversize_input() { + let mut reader = Cursor::new(b"abcde".to_vec()); + assert_eq!(read_to_string_limited(&mut reader, 4), ""); + } + + /// 손상 UTF-8도 파서에 전달하지 않고 빈 입력으로 안전 저하한다. + #[test] + fn read_to_string_limited_rejects_invalid_utf8() { + let mut reader = Cursor::new(vec![0xff, 0xfe, 0xfd]); + assert_eq!(read_to_string_limited(&mut reader, 8), ""); + } + #[test] fn has_extra_args_detects_surplus() { assert!(!has_extra_args(&["pulse".to_string()])); diff --git a/src/render.rs b/src/render.rs index f652ac6..acbee30 100644 --- a/src/render.rs +++ b/src/render.rs @@ -915,6 +915,60 @@ mod tests { /// env를 변경하는 테스트는 이 락을 잡아 직렬 실행한다. static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// 프로세스 환경변수의 이전 값을 저장했다가 scope 종료 시 복원하는 테스트용 RAII guard. + /// + /// 반드시 [`ENV_LOCK`]을 잡은 뒤 생성해야 한다. `Drop`도 락이 살아 있는 동안 실행되도록 테스트에서 + /// lock guard보다 뒤에 바인딩한다. + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: 호출자는 ENV_LOCK을 잡고 있으며, 이 guard가 drop될 때까지 lock guard가 살아 있다. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: 호출자는 ENV_LOCK을 잡고 있으며, 이 guard가 drop될 때까지 lock guard가 살아 있다. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: 테스트는 EnvVarGuard를 ENV_LOCK guard보다 뒤에 바인딩하므로 drop도 lock 내부에서 + // 실행된다. 이전 값 복원/삭제는 테스트 전역 환경을 원상복구하기 위한 단일 스레드 구간이다. + unsafe { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + #[test] + fn env_var_guard_restores_previous_value() { + let _guard = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _outer = EnvVarGuard::set("NO_COLOR", "preexisting"); + { + let _inner = EnvVarGuard::remove("NO_COLOR"); + assert_eq!(std::env::var_os("NO_COLOR"), None); + } + assert_eq!( + std::env::var_os("NO_COLOR"), + Some(std::ffi::OsString::from("preexisting")) + ); + } + #[test] fn render_no_color_env_has_no_escape_bytes() { let _guard = ENV_LOCK @@ -923,10 +977,8 @@ mod tests { // NO_COLOR이 설정되면 truecolor 모드라도 ANSI를 일절 출력하지 않아야 한다. let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); - // SAFETY: ENV_LOCK으로 직렬화된 단일 스레드 구간에서만 env를 변경한다. - unsafe { std::env::set_var("NO_COLOR", "1") }; + let _env = EnvVarGuard::set("NO_COLOR", "1"); let line = render(&sample_input(), &sample_snap(95.0), &cfg, 1_000, true); - unsafe { std::env::remove_var("NO_COLOR") }; assert!( !line.contains('\x1b'), "NO_COLOR 설정 시 ANSI ESC 바이트가 없어야 함: {line:?}" @@ -953,7 +1005,7 @@ mod tests { let _guard = ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); let mut snap = sample_snap(95.0); @@ -982,7 +1034,7 @@ mod tests { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); // NO_COLOR이 다른 테스트에서 새지 않도록 보장. - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); let line = render(&sample_input(), &sample_snap(95.0), &cfg, 1_000, true); @@ -1015,7 +1067,7 @@ mod tests { let _guard = ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); // 글리프 틴트는 밴드2(#86a0b4) truecolor 시퀀스로 시작해야 한다. @@ -1038,7 +1090,7 @@ mod tests { let _guard = ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); let line = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false); @@ -1060,7 +1112,7 @@ mod tests { let _guard = ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); let line = render(&sample_input(), &sample_snap(10.0), &cfg, 0, false); @@ -1076,7 +1128,7 @@ mod tests { let _guard = ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); // (cpu%, 기대 글리프, 기대 밴드 틴트 truecolor 프리픽스). @@ -1104,7 +1156,7 @@ mod tests { let _guard = ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut cfg = Config::default(); cfg.color.mode = "truecolor".to_string(); // wave=0(phase=0.75, 22500ms) → high 테라코타 #b87848. @@ -1740,8 +1792,7 @@ mod tests { let _guard = ENV_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - // SAFETY: ENV_LOCK으로 직렬화된 단일 스레드 구간에서만 env를 만진다. - unsafe { std::env::remove_var("NO_COLOR") }; + let _env = EnvVarGuard::remove("NO_COLOR"); let mut input = sample_input(); input.rate_5h_countdown = None; let mut cfg = Config::default(); diff --git a/src/system.rs b/src/system.rs index b1ec313..ca011a6 100644 --- a/src/system.rs +++ b/src/system.rs @@ -17,6 +17,12 @@ extern "C" { fn getloadavg(loadavg: *mut f64, nelem: libc::c_int) -> libc::c_int; } +/// 렌더 핫패스에서 허용하는 CPU 더블샘플 대기 상한(ms). +/// +/// 기본 25ms 동작은 그대로 보존하되, config가 비정상적으로 큰 값을 지정해 statusline 렌더를 오래 +/// 블록하지 못하게 한다. 100ms는 노이즈 완화 여지를 남기면서 한 프레임 지연 예산을 유한하게 만든다. +const MAX_CPU_SAMPLE_WINDOW_MS: u64 = 100; + /// 한 번의 렌더에서 측정한 시스템 상태 스냅샷. /// /// 각 항목은 best-effort이며 실패/부재 시 안전 저하한다(배터리는 `Option`). @@ -123,6 +129,11 @@ fn sample_cpu_loadavg_fallback() -> f64 { loadavg_to_percent(loads[0], cpu_count()) } +/// 사용자 설정 CPU sample window를 렌더 핫패스 예산 안으로 정규화한다. +fn bounded_cpu_sample_window_ms(sample_window_ms: u64) -> u64 { + sample_window_ms.min(MAX_CPU_SAMPLE_WINDOW_MS) +} + /// 전 코어 busy/total 틱 합계를 담는 한 번의 더블샘플 스냅샷. struct CpuTickTotals { /// busy 틱(user + system + nice) 전 코어 합. @@ -197,18 +208,38 @@ fn snapshot_cpu_ticks() -> Option { /// 커널 CPU 틱 델타로 계산한다. 더블샘플 실패 시 loadavg 정규화로 저하하며, /// 폴백 공식은 0–100 클램프 `min(load1/hw.ncpu*100, 100)`이다(AC3, 패닉 금지). pub fn sample_cpu_reactive(sample_window_ms: u64) -> f64 { + sample_cpu_reactive_with( + sample_window_ms, + snapshot_cpu_ticks, + std::thread::sleep, + sample_cpu_loadavg_fallback, + ) +} + +/// [`sample_cpu_reactive`]의 테스트 가능한 코어. +/// +/// FFI 스냅샷 함수와 sleeper를 주입해, 라이브 CPU 상태나 wall-clock에 의존하지 않고 sample window cap +/// 적용과 더블샘플 산식을 검증한다. +fn sample_cpu_reactive_with( + sample_window_ms: u64, + mut snapshot: impl FnMut() -> Option, + mut sleep: impl FnMut(std::time::Duration), + mut fallback: impl FnMut() -> f64, +) -> f64 { + let sample_window_ms = bounded_cpu_sample_window_ms(sample_window_ms); + // 1차 스냅샷 → sample_window_ms 만큼 대기 → 2차 스냅샷. 어느 한쪽이라도 실패하면 // loadavg 폴백으로 저하한다. - let first = match snapshot_cpu_ticks() { + let first = match snapshot() { Some(snapshot) => snapshot, - None => return sample_cpu_loadavg_fallback(), + None => return fallback(), }; - std::thread::sleep(std::time::Duration::from_millis(sample_window_ms)); + sleep(std::time::Duration::from_millis(sample_window_ms)); - let second = match snapshot_cpu_ticks() { + let second = match snapshot() { Some(snapshot) => snapshot, - None => return sample_cpu_loadavg_fallback(), + None => return fallback(), }; // saturating_sub: 카운터 래핑/순서 역전 시 음수 델타를 0으로 방어. @@ -762,6 +793,60 @@ mod tests { assert_eq!(loadavg_to_percent(5.0, 0), 0.0); } + /// CPU double-sample window는 기본/정상 값은 보존하고 비정상 큰 값만 렌더 예산 안으로 제한한다. + #[test] + fn cpu_sample_window_is_capped_for_hot_path() { + assert_eq!(bounded_cpu_sample_window_ms(0), 0); + assert_eq!(bounded_cpu_sample_window_ms(25), 25); + assert_eq!( + bounded_cpu_sample_window_ms(MAX_CPU_SAMPLE_WINDOW_MS), + MAX_CPU_SAMPLE_WINDOW_MS + ); + assert_eq!( + bounded_cpu_sample_window_ms(MAX_CPU_SAMPLE_WINDOW_MS + 1), + MAX_CPU_SAMPLE_WINDOW_MS + ); + assert_eq!( + bounded_cpu_sample_window_ms(u64::MAX), + MAX_CPU_SAMPLE_WINDOW_MS + ); + } + + /// `sample_cpu_reactive` 코어가 실제 sleep 직전에 cap을 적용해야 한다. helper 단독 테스트만으로는 + /// 호출부 배선 회귀를 못 잡으므로, 주입형 코어에서 관측된 sleep duration과 산출 CPU%를 함께 검증한다. + #[test] + fn sample_cpu_reactive_core_applies_capped_sleep() { + let mut calls = 0; + let mut sleeps = Vec::new(); + + let cpu = sample_cpu_reactive_with( + u64::MAX, + || { + calls += 1; + match calls { + 1 => Some(CpuTickTotals { + busy: 1_000, + total: 2_000, + }), + 2 => Some(CpuTickTotals { + busy: 1_050, + total: 2_200, + }), + _ => panic!("CPU snapshot called too many times"), + } + }, + |duration| sleeps.push(duration), + || panic!("fallback should not be used for successful snapshots"), + ); + + assert_eq!(calls, 2); + assert_eq!( + sleeps, + vec![std::time::Duration::from_millis(MAX_CPU_SAMPLE_WINDOW_MS)] + ); + assert_eq!(cpu, 25.0, "busy_delta=50,total_delta=200 → 25%"); + } + /// 실측 더블샘플/메모리 경로가 항상 0..=100 범위를 지키는지 무패닉으로 확인한다. /// (FFI 산식 자체는 위 순수 함수 테스트가 검증하며, 여기서는 라이브 경로의 /// 범위 불변식과 무패닉만 보장한다.) diff --git a/tests/oneline.rs b/tests/oneline.rs index b46ab00..e851f11 100644 --- a/tests/oneline.rs +++ b/tests/oneline.rs @@ -13,6 +13,10 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// chain 실행 여부를 stdout으로 직접 관측하기 위한 센티널. chain_command가 도는 경우에만 /// 자식 stdout에 합성되어 self 세그먼트와 함께 한 줄에 나타난다. const CHAIN_SENTINEL: &str = "CHAINSENTINEL"; +/// 프로덕션 `MAX_RENDER_STDIN_BYTES`(src/main.rs)와 동기화된 통합 테스트 상한. +const RENDER_STDIN_LIMIT_BYTES: usize = 1024 * 1024; +/// 프로덕션 `MAX_CONFIG_BYTES`(src/config.rs)와 동기화된 통합 테스트 상한. +const CONFIG_LIMIT_BYTES: usize = 256 * 1024; /// 병렬 테스트 스레드 간 임시 경로 충돌을 막는 프로세스 전역 단조 카운터. /// @@ -80,6 +84,40 @@ fn run_understatus_with_config(args: &[&str], stdin: &str, config_path: &str) -> output.stdout } +/// HOME 캐시 루트까지 격리해 understatus를 실행한다. +fn run_understatus_isolated_home( + args: &[&str], + stdin: &str, + config_path: &str, + home: &str, +) -> Vec { + let mut child = Command::new(env!("CARGO_BIN_EXE_understatus")) + .args(args) + .env("NO_COLOR", "1") + .env("UNDERSTATUS_CONFIG", config_path) + .env("HOME", home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("understatus 바이너리 실행 실패"); + + child + .stdin + .take() + .expect("stdin 핸들 없음") + .write_all(stdin.as_bytes()) + .expect("stdin 쓰기 실패"); + + let output = child.wait_with_output().expect("자식 종료 대기 실패"); + assert!( + output.status.success(), + "종료 코드 비정상: {:?}", + output.status + ); + output.stdout +} + /// chain_command가 센티널을 출력하도록 설정한 임시 config.toml을 만들고 그 경로를 반환한다. /// /// chain 자식은 `sh -c `로 실행되므로 `printf CHAINSENTINEL`이 도는지로 chain @@ -102,6 +140,27 @@ fn write_chain_config(tag: &str) -> String { path.to_string_lossy().into_owned() } +/// chain 자식이 전달받은 stdin byte 수를 `LEN:`으로 출력하는 임시 config를 만든다. +fn write_stdin_len_chain_config(tag: &str) -> String { + let path = std::env::temp_dir().join(format!( + "understatus-chain-len-cfg-{}-{tag}.toml", + unique_token() + )); + let command = "bytes=$(wc -c | tr -d ' '); printf 'LEN:%s' \"$bytes\""; + let toml = format!( + "[chain]\nchain_command = {command:?}\nchain_cache_ttl_seconds = 0\n[display]\nmax_width = 200\nshow_network = false\nshow_disk = false\nshow_battery = false\n" + ); + std::fs::write(&path, toml).expect("stdin 길이 chain config 작성 실패"); + path.to_string_lossy().into_owned() +} + +/// 테스트별 HOME 캐시 루트를 만든다(chain/pulse/net 캐시 간섭 방지). +fn make_isolated_home(tag: &str) -> String { + let path = std::env::temp_dir().join(format!("understatus-home-{}-{tag}", unique_token())); + std::fs::create_dir_all(&path).expect("격리 HOME 생성 실패"); + path.to_string_lossy().into_owned() +} + /// --oneline은 정확히 1행을 후행 개행 없이 출력해야 한다(spec §6.3). #[test] fn oneline_emits_single_line_without_trailing_newline() { @@ -134,6 +193,65 @@ fn default_render_has_trailing_newline() { ); } +/// oversized stdin은 실제 render 공개 경로에서 빈 입력으로 안전 저하하며, chain 자식에 원문을 +/// 전달하지 않아야 한다. helper 단위 테스트를 넘어 `read_stdin()` 배선 회귀를 잡는다. +#[test] +fn oversized_stdin_is_not_forwarded_to_chain() { + let config = write_stdin_len_chain_config("oversized-stdin"); + let home = make_isolated_home("oversized-stdin"); + let oversized = "x".repeat(RENDER_STDIN_LIMIT_BYTES + 1); + + let stdout = run_understatus_isolated_home(&["render"], &oversized, &config, &home); + let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함"); + + assert!( + text.contains("LEN:0"), + "oversized stdin은 빈 raw_stdin으로 chain에 전달되어야 함: {text:?}" + ); + assert!( + !text.contains(&format!("LEN:{}", RENDER_STDIN_LIMIT_BYTES + 1)), + "oversized 원문 길이가 chain에 노출되면 안 됨: {text:?}" + ); + + let _ = std::fs::remove_file(&config); + let _ = std::fs::remove_dir_all(&home); +} + +/// oversized config는 `UNDERSTATUS_CONFIG`를 통한 실제 render 공개 경로에서도 파싱되지 않고 기본값으로 +/// 저하해야 한다. 만약 unbounded read로 회귀하면 chain sentinel이 출력되어 이 테스트가 실패한다. +#[test] +fn oversized_config_is_ignored_by_render_path() { + let path = std::env::temp_dir().join(format!( + "understatus-oversized-render-cfg-{}.toml", + unique_token() + )); + let mut toml = format!("[chain]\nchain_command = \"printf {CHAIN_SENTINEL}\"\n#"); + toml.push_str(&"x".repeat(CONFIG_LIMIT_BYTES + 1)); + std::fs::write(&path, toml).expect("oversized config 작성 실패"); + let config = path.to_string_lossy().into_owned(); + let home = make_isolated_home("oversized-config"); + + let stdout = run_understatus_isolated_home( + &["render"], + r#"{"session_id":"oversized-config"}"#, + &config, + &home, + ); + let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함"); + + assert!( + !text.contains(CHAIN_SENTINEL), + "oversized config는 파싱되지 않아 chain_command가 실행되면 안 됨: {text:?}" + ); + assert!( + text.contains('%'), + "기본 설정 렌더는 계속 성공해야 함: {text:?}" + ); + + let _ = std::fs::remove_file(&config); + let _ = std::fs::remove_dir_all(&home); +} + /// --oneline은 chain을 수행하지 않는다(실제 chain_command 설정 상태에서 직접 증명). /// /// 기본 config 대신 chain_command(센티널 출력)가 설정된 임시 config를 주입해 chain 실행