diff --git a/Cargo.lock b/Cargo.lock index 1b15a3d..18a28ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,6 +415,7 @@ dependencies = [ "chrono", "clap", "colored", + "flate2", "globset", "lindera", "lindera-tantivy", @@ -426,6 +427,7 @@ dependencies = [ "serde_yaml", "sha2", "tantivy", + "tar", "tempfile", "toml", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 255b339..b08a57b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ tree-sitter-python = "0.25" rusqlite = { version = "0.31", features = ["bundled"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } toml = "0.8" +tar = "0.4" +flate2 = "1" [dev-dependencies] tempfile = "3" diff --git a/dev-reports/design/issue-77-design-policy.md b/dev-reports/design/issue-77-design-policy.md new file mode 100644 index 0000000..226d180 --- /dev/null +++ b/dev-reports/design/issue-77-design-policy.md @@ -0,0 +1,452 @@ +# 設計方針書 - Issue #77: インデックス共有モード + +## 1. 概要 + +CI/CDパイプラインやチーム共有サーバーでインデックスを事前生成し、`export`/`import` サブコマンドでチームメンバーが再利用できる仕組みを提供する。また `status --verify` でインデックスの整合性チェックを行う。 + +## 2. レイヤー構成と責務 + +### 新規モジュール + +| レイヤー | モジュール | 責務 | +|---------|-----------|------| +| **CLI** | `src/cli/export.rs` | エクスポートサブコマンドの実行ロジック(tar.gz圧縮含む) | +| **CLI** | `src/cli/import_index.rs` | インポートサブコマンドの実行ロジック(tar.gz展開・パス検証含む) | +| **Indexer** | `src/indexer/snapshot.rs` | ExportMeta 構造体定義 + export_meta.json の読み書きのみ(SRP) | + +> **設計判断**: snapshot.rs の責務を ExportMeta の型定義とシリアライズ/デシリアライズに限定する。tar.gz 操作は export.rs / import_index.rs の責務とし、パストラバーサル検証は import_index.rs 内の private 関数として実装する。 + +### 変更対象モジュール + +| モジュール | 変更内容 | +|-----------|---------| +| `src/main.rs` | `Commands` enum に `Export` / `Import` バリアント追加 | +| `src/cli/mod.rs` | `pub mod export;` `pub mod import_index;` 追加 | +| `src/cli/status.rs` | `--verify` フラグ対応、`run()` に `verify: bool` 引数追加 | +| `src/indexer/mod.rs` | `pub mod snapshot;` 追加 | +| `Cargo.toml` | `tar`, `flate2` 依存追加 | + +### 変更不要モジュール + +既存の `index`, `search`, `update`, `clean`, `context`, `embed`, `config` コマンドには変更なし。 + +### 定数参照の方針 + +- CLI層 (`src/cli/`): `crate::indexer::commandindex_dir(path)` ヘルパー関数を使用 +- Indexer層 (`src/indexer/`): 同モジュール内の既存定数 `COMMANDINDEX_DIR`, `TANTIVY_DIR` を使用 + +## 3. 技術選定 + +| カテゴリ | 選定技術 | 選定理由 | +|---------|---------|---------| +| tar操作 | `tar` (0.4) | Rust標準的なtarクレート、既にトランジティブ依存として存在 | +| gzip圧縮 | `flate2` (1, default features = miniz_oxide) | 純Rustバックエンド、クロスコンパイル影響なし | +| Git情報取得 | `fn current_git_hash(repo_path: &Path) -> Option` | export.rs 内のユーティリティ関数として分離。テスト時はモック可能 | + +## 4. データ構造設計 + +### 4.1 ExportMeta(エクスポートメタデータ) + +```rust +// src/indexer/snapshot.rs + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExportMeta { + pub export_format_version: u32, // 初期値: 1、前方互換ポリシー + pub commandindex_version: String, // env!("CARGO_PKG_VERSION") + pub git_commit_hash: Option, // エクスポート時のHEADコミットハッシュ + pub exported_at: DateTime, // エクスポート日時 +} +``` + +> **設計変更**: `index_root` を ExportMeta から削除。エクスポート元の絶対パス漏洩を防止するため。 + +> **バージョン互換性ポリシー**: `export_format_version` は整数インクリメント。import 側は「自身が対応する最大バージョン以下であれば受け入れる」前方互換ポリシー。新フィールド追加時に旧バージョンの import を壊さない。 + +### 4.2 ExportOptions / ImportOptions / ExportResult / ImportResult + +```rust +// src/cli/export.rs + +pub struct ExportOptions { + pub with_embeddings: bool, +} + +pub struct ExportResult { + pub output_path: PathBuf, + pub archive_size: u64, + pub git_commit_hash: Option, +} + +// src/cli/import_index.rs + +pub struct ImportOptions { + pub force: bool, +} + +pub struct ImportResult { + pub imported_files: u64, + pub git_hash_match: bool, + pub warnings: Vec, +} +``` + +> **設計判断**: 既存の IndexSummary/CleanResult パターンに合わせ、ExportResult/ImportResult 構造体で結果を返す。 + +### 4.3 status.rs の verify 対応 + +```rust +// src/cli/status.rs — run() シグネチャ + +pub fn run( + path: &Path, + format: StatusFormat, // format は別引数のまま(CleanOptions パターン準拠) + verify: bool, // 新規追加 + writer: &mut dyn Write, +) -> Result<(), StatusError> +``` + +> **設計判断**: `StatusOptions` 構造体は導入しない。`format` は別引数のまま維持し、`verify: bool` のみ追加する。既存の CleanOptions が `keep_embeddings` のみ(format は含まない)であるパターンに合わせ、オプション構造体の導入は実際の要求が発生してからとする(YAGNI)。既存テストは `verify: false` を追加するだけの最小修正。 + +### 4.4 VerifyResult(整合性チェック結果) + +```rust +// src/cli/status.rs + +#[derive(Debug, Serialize)] +pub struct VerifyResult { + pub state_valid: bool, + pub tantivy_valid: bool, + pub manifest_valid: bool, + pub symbols_valid: bool, + pub issues: Vec, +} + +#[derive(Debug, Serialize)] +pub struct VerifyIssue { + pub component: String, + pub severity: VerifySeverity, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub enum VerifySeverity { + Error, + Warning, +} +``` + +## 5. エラー型設計 + +### 5.1 ExportError + +```rust +// src/cli/export.rs + +#[derive(Debug)] +pub enum ExportError { + NotInitialized, + Io(std::io::Error), + State(StateError), + Manifest(ManifestError), + Serialize(serde_json::Error), + GitError(String), +} + +// 既存パターン準拠: impl fmt::Display, impl std::error::Error (source付き), impl From +impl fmt::Display for ExportError { ... } +impl std::error::Error for ExportError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { ... } } +impl From for ExportError { ... } +impl From for ExportError { ... } +impl From for ExportError { ... } +impl From for ExportError { ... } +``` + +### 5.2 ImportError + +```rust +// src/cli/import_index.rs + +#[derive(Debug)] +pub enum ImportError { + Io(std::io::Error), + ExistingIndex(PathBuf), + PathTraversal(String), + SymlinkDetected(PathBuf), // シンボリックリンク/ハードリンク検出 + InvalidArchive(String), + IncompatibleVersion { expected: u32, found: u32 }, + DecompressionBomb { limit: u64 }, // 展開サイズ上限超過 + State(StateError), + Deserialize(serde_json::Error), +} + +// 同様に impl fmt::Display, impl std::error::Error, impl From +``` + +## 6. 処理フロー + +### 6.1 エクスポートフロー + +``` +commandindex export [--with-embeddings] + 1. .commandindex/ の存在確認(NotInitialized エラー) + 2. IndexState::load() でインデックス状態読み込み + 3. current_git_hash(path) でコミットハッシュ取得(失敗時は None) + 4. ExportMeta を生成(index_root は含めない) + 5. export 前バリデーション: + - tantivy ドキュメント内の path が相対パスであることを確認 + - symbols.db 内の file_path が相対パスであることを確認 + 6. tar::Builder + flate2::GzEncoder でストリーミング圧縮 + - export_meta.json を最初に追加 + - state.json を追加(index_root を placeholder に置換してからパック) + - manifest.json, symbols.db を追加 + - tantivy/ ディレクトリを再帰的に追加 + - --with-embeddings 時のみ embeddings.db を追加 + - config.local.toml は常に除外 + 7. ExportResult を返す(出力パス、サイズ、git hash) +``` + +### 6.2 インポートフロー + +``` +commandindex import [--force] + 1. アーカイブファイルの存在確認 + 2. .commandindex/ の既存チェック + - 存在する場合: --force なしならエラー、ありなら警告後に削除 + 3. flate2::GzDecoder + tar::Archive でストリーミング展開 + 各エントリに対して: + a. パストラバーサルチェック(絶対パス拒否、.. 拒否) + b. シンボリックリンク/ハードリンク拒否(entry_type チェック) + c. 累積展開サイズチェック(上限: 1GB、エントリ数上限: 10000) + d. ファイルパーミッション固定(0o644/0o755) + - 不正検出時は即座にエラー、展開済みファイルをクリーンアップ + 4. export_meta.json を読み込み + - export_format_version の互換性チェック(前方互換ポリシー) + - commandindex_version の長さバリデーション + 5. state.json の index_root をインポート先の絶対パスに書き換え + 6. import 後バリデーション: + - manifest.json 内の各 FileEntry.path が相対パスであることを確認 + - symbols.db 内のパスが相対パスであることを確認 + 7. git rev-parse HEAD で現在のコミットハッシュ取得 + - export_meta の git_commit_hash と比較 + - 不一致時に警告メッセージ表示 + 8. tantivy インデックスのオープン確認 + 9. ImportResult を返す +``` + +### 6.3 整合性チェックフロー + +``` +commandindex status --verify + 1. .commandindex/ の存在確認 + 2. state.json の読み込みと schema_version チェック + 3. tantivy/ ディレクトリの存在確認 + 4. tantivy::Index::open_in_dir() でオープン可能性チェック + 5. manifest.json の読み込みと各ファイルの存在確認 + 6. symbols.db のオープンとスキーマバージョン確認 + 7. VerifyResult を構築して出力 +``` + +## 7. セキュリティ設計 + +| 脅威 | 対策 | 実装方針 | +|------|------|---------| +| パストラバーサル(ファイルパス) | 文字列レベルの検証 | 絶対パス拒否 + `..` コンポーネント拒否 + join 後の components() 再チェック。**canonicalize() は使わない**(展開前にファイルが存在しないため) | +| パストラバーサル(シンボリックリンク) | entry_type チェック | `Symlink` / `Link` エントリは即座に拒否。展開済み親ディレクトリの `symlink_metadata()` 確認。clean.rs の SymlinkDetected パターン踏襲 | +| パストラバーサル(ハードリンク) | entry_type チェック | `Link`(ハードリンク)エントリも拒否 | +| 機密情報漏洩(ファイル) | config.local.toml 除外 | エクスポート時に明示的スキップ | +| 機密情報漏洩(パス) | index_root サニタイズ | state.json の index_root を placeholder に置換してパック。export_meta.json には index_root を含めない | +| 圧縮爆弾 | サイズ/エントリ数上限 | 累積展開バイト数上限 (1GB)、個別エントリサイズ上限、エントリ数上限 (10000) | +| 悪意あるメタデータ | デシリアライズ検証 | `#[serde(deny_unknown_fields)]`、文字列長上限チェック | +| 権限昇格 | パーミッション固定 | 展開ファイルのパーミッションを 0o644/0o755 に固定、tarエントリの権限情報は無視 | + +### パストラバーサル検証の実装方針 + +```rust +fn validate_entry_path(entry_path: &Path, target_dir: &Path) -> Result { + // 1. 絶対パスの拒否 + if entry_path.is_absolute() { + return Err(ImportError::PathTraversal(format!("absolute path: {:?}", entry_path))); + } + // 2. ".." コンポーネントの拒否 + for component in entry_path.components() { + if matches!(component, std::path::Component::ParentDir) { + return Err(ImportError::PathTraversal(format!("parent dir: {:?}", entry_path))); + } + } + // 3. 展開先パスの構築 + let full_path = target_dir.join(entry_path); + // 4. 正規化後の prefix チェック(canonicalize は使わない) + // components() で再構築して target_dir で始まることを確認 + Ok(full_path) +} + +fn validate_entry_type(entry: &tar::Entry) -> Result<(), ImportError> { + match entry.header().entry_type() { + tar::EntryType::Symlink | tar::EntryType::Link => { + Err(ImportError::SymlinkDetected(entry.path()?.to_path_buf())) + } + _ => Ok(()), + } +} +``` + +## 8. CLIインターフェース設計 + +### main.rs への追加 + +```rust +// Commands enum に追加(doc comment は英語で統一) +/// Export index as portable tar.gz archive +Export { + /// Output file path (.tar.gz) + output: PathBuf, + /// Include embedding database + #[arg(long)] + with_embeddings: bool, +}, +/// Import index from tar.gz archive +Import { + /// Input archive file path (.tar.gz) + input: PathBuf, + /// Overwrite existing index + #[arg(long)] + force: bool, +}, + +// Status バリアントに verify 追加 +Status { + #[arg(long, default_value = ".")] + path: PathBuf, + #[arg(long, value_enum, default_value_t = StatusFormat::Human)] + format: StatusFormat, + /// Verify index integrity + #[arg(long)] + verify: bool, +}, +``` + +### run() 関数シグネチャ(既存パターン準拠) + +```rust +// export.rs — 第1引数は base path(既存パターン) +pub fn run(path: &Path, output: &Path, options: &ExportOptions) -> Result + +// import_index.rs — 第1引数は base path、第2引数は archive path +pub fn run(path: &Path, archive: &Path, options: &ImportOptions) -> Result + +// status.rs — format は別引数のまま、verify を追加 +pub fn run(path: &Path, format: StatusFormat, verify: bool, writer: &mut dyn Write) -> Result<(), StatusError> +``` + +## 9. アーカイブ内部構造 + +``` +index-snapshot.tar.gz +├── export_meta.json # エクスポートメタデータ(最初のエントリ) +├── state.json # インデックス状態(index_root はサニタイズ済み) +├── manifest.json # ファイルマニフェスト(相対パス) +├── symbols.db # シンボルデータベース(相対パス) +├── tantivy/ # tantivy インデックスディレクトリ +│ ├── meta.json +│ ├── .managed.json +│ └── *.{del,fast,fieldnorm,idx,pos,store,term} +└── embeddings.db # (--with-embeddings 時のみ) +``` + +**注意**: アーカイブ内のパスは `.commandindex/` プレフィックスなしのフラットな構造。インポート時に `.commandindex/` ディレクトリ内に展開される。commandindex.toml(リポジトリルート)はエクスポート対象外。 + +## 10. 設計判断とトレードオフ + +| 判断 | 選択 | 代替案 | 理由 | +|------|------|--------|------| +| CLI設計 | 独立サブコマンド (`export`/`import`) | `index --export`/`index --import` | 単一責任パターンとの整合性、clap排他制御の複雑化回避 | +| メタデータ | 別ファイル (`export_meta.json`) | `state.json` にフィールド追加 | state.json の後方互換性維持、エクスポート固有情報の分離 | +| パス検証 | 手動エントリ展開 | `Archive::unpack()` | セキュリティ:パストラバーサル防止を確実にするため | +| パス検証方式 | 文字列レベル + components() | `canonicalize()` | 展開前にファイルが存在しないため canonicalize() は使えない | +| embeddings.db | デフォルト除外 | デフォルト含む | モデル依存データであり、異なる環境では意味をなさない可能性 | +| アーカイブパス | フラットパス(プレフィックスなし) | `.commandindex/` プレフィックス付き | インポート時の柔軟性、展開先ディレクトリの明示的制御 | +| status拡張 | `verify: bool` 引数追加 | StatusOptions構造体 | YAGNI — CleanOptionsパターン準拠、構造体は実際に複数オプションが必要になってから | +| import モジュール名 | `import_index.rs` | `import.rs` | 既存モジュールは単一単語だが、import はキーワード衝突の可能性回避 | +| snapshot.rs の責務 | ExportMeta の型+読み書きのみ | tar操作も含む | SRP — tar操作は export.rs/import_index.rs の責務 | +| index_root 漏洩防止 | ExportMeta に含めない + state.json サニタイズ | そのまま含める | 絶対パスによるインフラ情報漏洩を防止 | +| 圧縮爆弾対策 | サイズ/エントリ数上限 | ストリーミングのみ | ストリーミングはメモリ節約のみ、ディスク枯渇は防げない | +| git hash 取得 | 分離関数 | snapshot.rs 内に含める | DIP — テスタビリティ向上、外部コマンド依存の分離 | + +## 11. 影響範囲 + +### 直接影響 + +| ファイル | 変更種別 | 影響度 | +|---------|---------|--------| +| `src/main.rs` | enum バリアント追加 + match 分岐追加 | 低 | +| `src/cli/mod.rs` | モジュール宣言追加 | 低 | +| `src/cli/status.rs` | run() に verify 引数追加、verify ロジック実装 | 中 | +| `src/indexer/mod.rs` | モジュール宣言追加 | 低 | +| `Cargo.toml` | 依存追加 | 低 | +| `tests/cli_args.rs` | help テストに export/import 検証追加 | 低 | +| `tests/cli_status.rs` | run() 呼び出しに verify: false 追加(4箇所) | 低 | + +### 新規ファイル + +| ファイル | 内容 | +|---------|------| +| `src/cli/export.rs` | エクスポートサブコマンド + ExportResult/ExportError | +| `src/cli/import_index.rs` | インポートサブコマンド + ImportResult/ImportError | +| `src/indexer/snapshot.rs` | ExportMeta 構造体 + 読み書き | +| `tests/cli_export.rs` | エクスポート統合テスト | +| `tests/cli_import.rs` | インポート統合テスト | +| `tests/e2e_export_import.rs` | export → import → search E2Eテスト | + +### 間接影響 + +- 既存の `index`, `search`, `update`, `clean`, `embed`, `context`, `config` コマンド: **影響なし** +- CI/CDパイプライン: **変更不要**(tar/flate2 は純Rust、クロスコンパイル問題なし) +- export 成果物の出力先: `.commandindex/` 外(ユーザー指定パス)のため clean コマンドに影響なし + +### import 後の既存コマンドとの整合性 + +- `update`: import 後に実行可能。index_root 書き換え済みのため差分検出が正常動作する(統合テストで検証必須) +- `clean`: import したインデックスも通常通り削除可能 +- `search`: import 後の検索が正常動作する(E2Eテストで検証必須) + +## 12. テスト戦略 + +### 新規テスト + +| テストファイル | テスト内容 | 種別 | +|--------------|-----------|------| +| `tests/cli_export.rs` | export 基本動作、NotInitialized エラー | 統合 | +| | config.local.toml がエクスポートに含まれないことの検証 | セキュリティ | +| | embeddings.db のデフォルト除外と --with-embeddings | 統合 | +| `tests/cli_import.rs` | import 基本動作 | 統合 | +| | 既存インデックスありで --force なしのエラー | 統合 | +| | 既存インデックスありで --force のインポート | 統合 | +| | パストラバーサル検出テスト(`../`, 絶対パス) | セキュリティ | +| | シンボリックリンクエントリ拒否テスト | セキュリティ | +| | ハードリンクエントリ拒否テスト | セキュリティ | +| | 圧縮爆弾検出テスト(サイズ上限超過) | セキュリティ | +| | export_format_version 不一致時のエラー | 統合 | +| | コミットハッシュ不一致時の警告 | 統合 | +| `tests/e2e_export_import.rs` | export → import → search の E2E フロー | E2E | +| | import 後に update が正常動作するか | E2E | +| | import 後に tantivy インデックスがオープンできるか | E2E | +| `tests/e2e_verify.rs` | 正常インデックスの verify パス | E2E | +| | 破損インデックスの verify エラー検出 | E2E | + +### 既存テスト修正 + +| テストファイル | 修正内容 | +|--------------|---------| +| `tests/cli_args.rs` | `help_flag_shows_usage` に export/import の検証追加 | +| `tests/cli_status.rs` | `run()` 呼び出しに `verify: false` 追加(4箇所) | + +## 13. 品質基準 + +| チェック項目 | コマンド | 基準 | +|-------------|----------|------| +| ビルド | `cargo build` | エラー0件 | +| Clippy | `cargo clippy --all-targets -- -D warnings` | 警告0件 | +| テスト | `cargo test --all` | 全テストパス | +| フォーマット | `cargo fmt --all -- --check` | 差分なし | diff --git a/dev-reports/issue/77/issue-review/hypothesis-verification.md b/dev-reports/issue/77/issue-review/hypothesis-verification.md new file mode 100644 index 0000000..95f30c3 --- /dev/null +++ b/dev-reports/issue/77/issue-review/hypothesis-verification.md @@ -0,0 +1,6 @@ +# 仮説検証レポート - Issue #77 + +## 結果: スキップ + +Issue #77 は新機能提案(インデックス共有モード)であり、仮説・原因分析の記載はありません。 +仮説検証フェーズをスキップします。 diff --git a/dev-reports/issue/77/issue-review/original-issue.json b/dev-reports/issue/77/issue-review/original-issue.json new file mode 100644 index 0000000..d698df7 --- /dev/null +++ b/dev-reports/issue/77/issue-review/original-issue.json @@ -0,0 +1 @@ +{"body":"## 概要\n\nCI/CDパイプラインやチーム共有サーバーでインデックスを事前生成し、チームメンバーが再利用できる仕組みを提供する。\n\n## 背景・動機\n\n大規模リポジトリでは `commandindex index` の初回実行に時間がかかる。CIでインデックスを事前生成し、チームメンバーがダウンロードして利用できると、オンボーディングや日常利用が効率化する。\n\n## 提案する解決策\n\n### インデックスのエクスポート/インポート\n\n```bash\n# インデックスをtar.gzにエクスポート\ncommandindex index --export index-snapshot.tar.gz\n\n# エクスポートされたインデックスをインポート\ncommandindex index --import index-snapshot.tar.gz\n\n# インデックスの整合性チェック\ncommandindex status --verify\n```\n\n### CI連携例\n\n```yaml\n# GitHub Actions\n- name: Build CommandIndex\n run: |\n commandindex index\n commandindex index --export /tmp/commandindex-snapshot.tar.gz\n\n- name: Upload artifact\n uses: actions/upload-artifact@v4\n with:\n name: commandindex-snapshot\n path: /tmp/commandindex-snapshot.tar.gz\n```\n\n### エクスポートフォーマット\n\n- `.commandindex/` ディレクトリをtar.gzで圧縮\n- `state.json` にエクスポート時のメタデータ(コミットハッシュ、タイムスタンプ)を追加\n- インポート時にコミットハッシュの一致を確認(不一致の場合は警告)\n\n### --verify オプション\n\n- tantivy インデックスの整合性チェック\n- `manifest.json` とファイルシステムの一致確認\n- `symbols.db` のスキーマバージョン確認\n\n## 受け入れ基準\n\n- [ ] `--export` でインデックスをtar.gzにエクスポートできる\n- [ ] `--import` でインデックスをインポートできる\n- [ ] インポート後の検索が正常に動作する\n- [ ] `status --verify` でインデックスの整合性チェックができる\n- [ ] コミットハッシュ不一致時に警告メッセージが表示される\n- [ ] cargo test / clippy / fmt 全パス\n\n## 依存 Issue\n\n- #76 チーム共有設定ファイル","title":"[Feature] インデックス共有モード(--shared / CI連携)"} diff --git a/dev-reports/issue/77/issue-review/stage1-review-context.json b/dev-reports/issue/77/issue-review/stage1-review-context.json new file mode 100644 index 0000000..6da058d --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage1-review-context.json @@ -0,0 +1,81 @@ +{ + "must_fix": [ + { + "id": "M1", + "title": "CLIインターフェース設計がサブコマンド構成と不整合", + "detail": "Issueでは `commandindex index --export` / `commandindex index --import` と既存の `index` サブコマンドにフラグを追加する形を提案しているが、既存の `Index` サブコマンドは `--path` と `--with-embedding` のみを持つインデックス構築用コマンドであり、エクスポート/インポートはインデックス構築とは本質的に異なる操作である。--export/--import を index のフラグにすると、--path や --with-embedding との排他制御が必要になり、clap定義が複雑化する。", + "suggestion": "エクスポート/インポートは独立したサブコマンド `commandindex export ` / `commandindex import ` として設計するか、`commandindex index export` / `commandindex index import` のようにネストしたサブコマンドにする方が既存のCLI設計パターンと整合する。" + }, + { + "id": "M2", + "title": "state.jsonにコミットハッシュのフィールドが存在しない", + "detail": "Issueでは「state.json にエクスポート時のメタデータ(コミットハッシュ、タイムスタンプ)を追加」と記載しているが、現在の IndexState 構造体には git_commit_hash フィールドが存在しない。追加時の後方互換性への影響を考慮する必要がある。", + "suggestion": "IndexState にオプショナルフィールド `#[serde(default)] pub git_commit_hash: Option` を追加するか、エクスポート専用のメタデータファイル(export_meta.json)をアーカイブ内に別途含める設計を明記すべき。" + }, + { + "id": "M3", + "title": "セキュリティ: tar展開時のパストラバーサル対策が未記載", + "detail": "信頼できないソースからのtar.gzアーカイブをインポートする場合、パストラバーサル攻撃が成立する可能性がある。受け入れ基準にセキュリティ要件が含まれていない。", + "suggestion": "受け入れ基準に「インポート時、アーカイブ内の全エントリが展開先ディレクトリ内に収まることを検証し、パストラバーサルを検出した場合はエラーで中断する」を追加する。" + } + ], + "should_fix": [ + { + "id": "S1", + "title": "既存インデックスがある場合のインポート動作が未定義", + "detail": "インポート先に既に .commandindex/ が存在する場合の挙動が未記載。", + "suggestion": "「既存インデックスがある場合は警告を表示し、--force フラグがなければ中断する」を追加する。" + }, + { + "id": "S2", + "title": "index_root パスの不整合問題が未考慮", + "detail": "state.json の index_root にはインデックス作成時の絶対パスが保存されるが、インポート先では異なるパスになる。", + "suggestion": "インポート時に index_root をインポート先のパスに自動書き換える処理を仕様に含めるべき。" + }, + { + "id": "S3", + "title": "symbols.db と embeddings.db のポータビリティ考慮不足", + "detail": "SQLiteデータベースファイル内のファイルパスが相対パスか絶対パスかによってポータビリティが変わる。", + "suggestion": "エクスポート対象ファイル一覧と各ファイル内のパスが相対パスであることの前提条件を明記する。" + }, + { + "id": "S4", + "title": "--verify の検証範囲が曖昧", + "detail": "「manifest.json とファイルシステムの一致確認」が具体的に何を指すか不明確。", + "suggestion": "各チェック項目を具体的に定義する。例: (1) state.json の schema_version 確認、(2) tantivy ディレクトリの存在と読み取り可能性、(3) manifest.json に記載されたファイルの存在確認、(4) symbols.db の schema_version 確認。" + }, + { + "id": "S5", + "title": "tar.gz の依存クレート選定が未検討", + "detail": "Cargo.toml に tar/flate2 等の圧縮関連クレートが含まれていない。", + "suggestion": "使用予定のクレート(tar + flate2)を明記する。" + } + ], + "nice_to_have": [ + { + "id": "N1", + "title": "エクスポートファイルのバージョニング", + "detail": "将来のスキーマ変更に備え、エクスポートファイル自体にフォーマットバージョンを含めると便利。", + "suggestion": "アーカイブ内に export_meta.json を含め、export_format_version, commandindex_version 等を記録する。" + }, + { + "id": "N2", + "title": "差分インポートとの連携", + "detail": "インポート後に commandindex update で差分更新できるかどうかが未記載。", + "suggestion": "インポート後に差分更新が正常動作することを確認する旨を追記する。" + }, + { + "id": "N3", + "title": "エクスポート/インポートの進捗表示", + "detail": "大規模リポジトリでは圧縮・展開に時間がかかる可能性がある。", + "suggestion": "初期実装ではサイズ表示のみとし、将来的にプログレスバーを追加する。" + }, + { + "id": "N4", + "title": "config.local.toml の除外", + "detail": "エクスポート時に個人設定やAPIキーを含む可能性のある config.local.toml を含めるべきでない。", + "suggestion": "エクスポート対象から config.local.toml を明示的に除外する旨を記載する。" + } + ], + "summary": "Issue #77 は大規模リポジトリでのオンボーディング効率化という明確な価値を持つ良いFeatureリクエストである。ただし、(1) CLIインターフェース設計の不整合、(2) state.jsonへのコミットハッシュフィールド追加方針、(3) tar展開時のセキュリティ対策の3点が必須修正。加えて、index_rootの環境間差異、既存インデックスの上書きポリシー、--verifyの検証範囲、依存クレートの選定等の明確化が推奨される。" +} diff --git a/dev-reports/issue/77/issue-review/stage2-apply-result.json b/dev-reports/issue/77/issue-review/stage2-apply-result.json new file mode 100644 index 0000000..5cac6f2 --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage2-apply-result.json @@ -0,0 +1,20 @@ +{ + "stage": 2, + "applied_fixes": [ + {"id": "M1", "action": "CLIインターフェースを独立サブコマンド (export/import) に変更"}, + {"id": "M2", "action": "export_meta.json をアーカイブ内に含める設計に変更、state.jsonへの直接変更を回避"}, + {"id": "M3", "action": "セキュリティ要件セクションを追加、受け入れ基準にパストラバーサル検出を追加"}, + {"id": "S1", "action": "既存インデックスがある場合の動作セクションを追加、--force フラグ追加"}, + {"id": "S2", "action": "インポート時の index_root 自動書き換えを仕様に追加"}, + {"id": "S3", "action": "エクスポート対象ファイル一覧テーブルを追加、パス形式を明記"}, + {"id": "S4", "action": "--verify の各チェック項目を具体的に定義"}, + {"id": "S5", "action": "依存クレート(tar + flate2)セクションを追加"}, + {"id": "N1", "action": "export_meta.json にフォーマットバージョンを含める設計に採用"}, + {"id": "N4", "action": "config.local.toml の除外を明記"} + ], + "deferred": [ + {"id": "N2", "reason": "差分インポートは将来のエンハンスメントとして別Issue化が適切"}, + {"id": "N3", "reason": "進捗表示は初期実装のスコープ外"} + ], + "issue_updated": true +} diff --git a/dev-reports/issue/77/issue-review/stage3-review-context.json b/dev-reports/issue/77/issue-review/stage3-review-context.json new file mode 100644 index 0000000..3f6adf7 --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage3-review-context.json @@ -0,0 +1,81 @@ +{ + "must_fix": [ + { + "id": "M1", + "title": "tantivy インデックスの export/import 後の整合性検証が必要", + "detail": "tantivy の meta.json やセグメントファイルが export/import 後に正常にオープンできるかの検証が必須。", + "suggestion": "統合テストを追加し、export → import 後に tantivy インデックスが開けること・検索が動作することを検証する。" + }, + { + "id": "M2", + "title": "symbols.db / embeddings.db 内のパス整合性", + "detail": "SQLite 内のファイルパスは相対パスで格納されているが、index_root 書き換えのみで全検索ロジックが正常動作するか検証が必要。", + "suggestion": "index_root の書き換えのみで動作する設計方針をドキュメント化し、統合テストで検証する。" + }, + { + "id": "M3", + "title": "パストラバーサル防止の実装", + "detail": "tar クレートの Archive::unpack はデフォルトでパストラバーサルを防止しない。エントリごとのパス検証が必要。", + "suggestion": "エントリごとに path().starts_with(target_dir) をチェックし、悪意あるアーカイブのテストケースも追加する。" + }, + { + "id": "M4", + "title": "cli_args.rs テストへの新サブコマンド検証追加", + "detail": "help_flag_shows_usage テストに export/import の存在検証を追加すべき。", + "suggestion": "テストに .stdout(predicate::str::contains(\"export\")) と import の検証を追加。" + } + ], + "should_fix": [ + { + "id": "S1", + "title": "status --verify と既存 StatusFormat への影響", + "detail": "run() の関数シグネチャ変更で既存テスト (cli_status.rs 11個) の修正が発生する可能性。", + "suggestion": "StatusOptions 構造体を導入し後方互換性を保つ設計を推奨。" + }, + { + "id": "S2", + "title": "export_meta.json のバージョン互換性チェック", + "detail": "将来のバージョンアップ時に古いエクスポートをインポートできるよう設計が必要。", + "suggestion": "export_schema_version フィールドを設け、import 時にバージョン互換性チェックを実施。" + }, + { + "id": "S3", + "title": "cli/mod.rs へのモジュール宣言追加", + "detail": "export.rs / import.rs を追加する場合、mod.rs への宣言追加が必要。", + "suggestion": "アルファベット順で追加。" + }, + { + "id": "S4", + "title": "大規模インデックスのメモリ使用量", + "detail": "tar/gzip のストリーミング API を使用してメモリ使用量を抑制すべき。", + "suggestion": "ストリーミング圧縮/展開を使用する設計にする。" + }, + { + "id": "S5", + "title": "--force フラグなしでの既存インデックス保護", + "detail": "import 前に既存インデックスの存在チェックとエラーメッセージが必要。", + "suggestion": "IndexState::exists() でチェックし、明確なエラーメッセージで中断。" + } + ], + "nice_to_have": [ + { + "id": "N1", + "title": "tar/flate2 は既にトランジティブ依存として存在", + "detail": "lindera-dictionary の build-dependencies として既に依存ツリーに存在。バイナリサイズへの影響は最小限。", + "suggestion": "Cargo.toml に tar = \"0.4\" と flate2 = \"1\" を追加。" + }, + { + "id": "N2", + "title": "クロスコンパイルへの影響なし", + "detail": "tar/flate2 は純 Rust クレートでありC依存がないため問題なし。", + "suggestion": "特に対応不要。" + }, + { + "id": "N3", + "title": "embeddings.db の export 可否オプション", + "detail": "embeddings.db はモデル依存データであり、異なる環境では意味をなさない可能性がある。", + "suggestion": "デフォルトでは embeddings.db を含めず、--with-embeddings フラグで明示的に含める設計を推奨。" + } + ], + "summary": "新規サブコマンド追加が中心で既存機能への影響は低い。最も注意が必要なのは tantivy インデックスの整合性、パストラバーサル防止、index_root 書き換えの3点。依存クレートは既にトランジティブに存在しており、CI/CDへの影響は最小限。" +} diff --git a/dev-reports/issue/77/issue-review/stage4-apply-result.json b/dev-reports/issue/77/issue-review/stage4-apply-result.json new file mode 100644 index 0000000..3501fc1 --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage4-apply-result.json @@ -0,0 +1,20 @@ +{ + "stage": 4, + "applied_fixes": [ + {"id": "M1", "action": "tantivy インデックスの export/import 後の整合性検証を受け入れ基準に追加"}, + {"id": "M2", "action": "SQLite内のパスが相対パスであることの注記を追加"}, + {"id": "M3", "action": "セキュリティ要件にエントリごとのパス検証方法と悪意あるアーカイブのテストケースを追記"}, + {"id": "M4", "action": "実装上の注意点に cli_args.rs テスト更新を追記"}, + {"id": "S1", "action": "StatusOptions 構造体導入の方針を実装注意点に追記"}, + {"id": "S2", "action": "export_format_version の互換性チェックを受け入れ基準に追加"}, + {"id": "S3", "action": "cli/mod.rs へのモジュール追加を実装注意点に追記"}, + {"id": "S4", "action": "ストリーミングAPIの使用をエクスポートフォーマットセクションに追記"}, + {"id": "S5", "action": "明確なエラーメッセージ表示を既存インデックス保護セクションに追記"}, + {"id": "N3", "action": "embeddings.db をデフォルト除外、--with-embeddings フラグを追加"} + ], + "deferred": [ + {"id": "N1", "reason": "tar/flate2 のトランジティブ依存情報は依存クレートセクションに注記追加"}, + {"id": "N2", "reason": "クロスコンパイル影響なしの注記を依存クレートセクションに追加"} + ], + "issue_updated": true +} diff --git a/dev-reports/issue/77/issue-review/stage5-review-context.json b/dev-reports/issue/77/issue-review/stage5-review-context.json new file mode 100644 index 0000000..a8a3520 --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage5-review-context.json @@ -0,0 +1,9 @@ +{ + "stage": 5, + "status": "skipped", + "reason": "Codex (commandmatedev) がレスポンスを生成しなかったため、Stage 5 をスキップ。Stage 1-4 の Claude opus レビューで十分な品質改善が達成済み。", + "must_fix": [], + "should_fix": [], + "nice_to_have": [], + "summary": "Codex 通常レビュー(2回目)はスキップ。1回目レビュー (Stage 1-4) で Must Fix 7件を全て反映済み。" +} diff --git a/dev-reports/issue/77/issue-review/stage6-apply-result.json b/dev-reports/issue/77/issue-review/stage6-apply-result.json new file mode 100644 index 0000000..e65aa64 --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage6-apply-result.json @@ -0,0 +1,5 @@ +{ + "stage": 6, + "status": "skipped", + "reason": "Stage 5 がスキップされたため、Stage 6 もスキップ。" +} diff --git a/dev-reports/issue/77/issue-review/stage7-review-context.json b/dev-reports/issue/77/issue-review/stage7-review-context.json new file mode 100644 index 0000000..c1584e2 --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage7-review-context.json @@ -0,0 +1,9 @@ +{ + "stage": 7, + "status": "skipped", + "reason": "Codex がレスポンスを生成しなかったため、Stage 7 をスキップ。Stage 3-4 の Claude opus 影響範囲レビューで十分なカバレッジを達成済み。", + "must_fix": [], + "should_fix": [], + "nice_to_have": [], + "summary": "Codex 影響範囲レビュー(2回目)はスキップ。1回目影響範囲レビュー (Stage 3-4) の指摘事項は全て反映済み。" +} diff --git a/dev-reports/issue/77/issue-review/stage8-apply-result.json b/dev-reports/issue/77/issue-review/stage8-apply-result.json new file mode 100644 index 0000000..da3dd9d --- /dev/null +++ b/dev-reports/issue/77/issue-review/stage8-apply-result.json @@ -0,0 +1,5 @@ +{ + "stage": 8, + "status": "skipped", + "reason": "Stage 7 がスキップされたため、Stage 8 もスキップ。" +} diff --git a/dev-reports/issue/77/issue-review/summary-report.md b/dev-reports/issue/77/issue-review/summary-report.md new file mode 100644 index 0000000..b07b84f --- /dev/null +++ b/dev-reports/issue/77/issue-review/summary-report.md @@ -0,0 +1,52 @@ +# Issue #77 マルチステージレビュー サマリーレポート + +## 概要 +Issue #77「[Feature] インデックス共有モード(--shared / CI連携)」に対してマルチステージレビューを実施し、Issueの品質を大幅に改善した。 + +## 実施ステージ + +| Stage | 種別 | 実行エージェント | 結果 | +|-------|------|----------------|------| +| 0.5 | 仮説検証 | - | スキップ(新機能提案のため仮説なし) | +| 1 | 通常レビュー(1回目) | Claude opus | Must Fix: 3件, Should Fix: 5件, Nice to Have: 4件 | +| 2 | 指摘反映(1回目) | Claude sonnet | 10件反映、2件延期 | +| 3 | 影響範囲レビュー(1回目) | Claude opus | Must Fix: 4件, Should Fix: 5件, Nice to Have: 5件 | +| 4 | 指摘反映(1回目) | Claude sonnet | 10件反映、2件情報追記 | +| 5 | 通常レビュー(2回目) | Codex | スキップ(Codex応答なし) | +| 6 | 指摘反映(2回目) | - | スキップ | +| 7 | 影響範囲レビュー(2回目) | Codex | スキップ(Codex応答なし) | +| 8 | 指摘反映(2回目) | - | スキップ | + +## 主要な改善内容 + +### CLIインターフェース設計 (Must Fix) +- **変更前**: `commandindex index --export/--import` (既存サブコマンドへのフラグ追加) +- **変更後**: `commandindex export/import` (独立サブコマンド) +- **理由**: 既存のCLI設計パターン(単一責任)との整合性 + +### メタデータ管理 (Must Fix) +- **変更前**: state.json にコミットハッシュを直接追加 +- **変更後**: `export_meta.json` を別途アーカイブ内に含める設計 +- **理由**: 後方互換性の維持、エクスポート固有情報の分離 + +### セキュリティ (Must Fix) +- パストラバーサル防止の具体的実装方針を追加 +- 悪意あるアーカイブに対するテストケースの追加を明記 +- `config.local.toml` のエクスポート除外を明記 + +### 影響範囲の明確化 +- tantivy インデックスの export/import 後の整合性検証 +- `index_root` の自動書き換えによるポータビリティ確保 +- `embeddings.db` のデフォルト除外(`--with-embeddings` フラグ) +- `--verify` オプションの具体的チェック項目定義 +- 既存インデックスがある場合の `--force` フラグ動作 + +### 依存関係・CI影響 +- `tar` + `flate2` クレートは既にトランジティブ依存として存在 +- 純Rustクレートでありクロスコンパイル影響なし +- CI/CDパイプライン変更不要 + +## Issue 更新状況 +- GitHub Issue #77 は2回更新済み(Stage 2, Stage 4) +- 受け入れ基準: 6項目 → 14項目に拡充 +- 新規セクション追加: セキュリティ要件、エクスポート対象ファイル一覧、既存インデックスの動作、実装上の注意点、依存クレート diff --git a/dev-reports/issue/77/multi-stage-design-review/stage1-apply-result.json b/dev-reports/issue/77/multi-stage-design-review/stage1-apply-result.json new file mode 100644 index 0000000..654945a --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage1-apply-result.json @@ -0,0 +1 @@ +{"stage": 1, "applied": ["M1: verify を StatusOptions 構造体ではなく run() 引数に変更 (YAGNI)", "M2: snapshot.rs の責務を ExportMeta 定義+読み書きのみに限定 (SRP)", "S2: export_format_version の前方互換ポリシーを明記", "S3: git hash 取得を分離関数に (DIP)"]} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage1-review-context.json b/dev-reports/issue/77/multi-stage-design-review/stage1-review-context.json new file mode 100644 index 0000000..755de80 --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage1-review-context.json @@ -0,0 +1,21 @@ +{ + "stage": 1, + "type": "設計原則レビュー (SOLID/KISS/YAGNI/DRY)", + "must_fix": [ + {"id": "M1", "title": "VerifyResult/StatusOptions は YAGNI — verify は import 内部検証にとどめるべき"}, + {"id": "M2", "title": "snapshot.rs の SRP 違反 — 責務を ExportMeta 定義+読み書きに限定すべき"} + ], + "should_fix": [ + {"id": "S1", "title": "ExportError/ImportError の共通バリアント DRY 改善"}, + {"id": "S2", "title": "export_format_version の互換性ポリシー未定義 (OCP)"}, + {"id": "S3", "title": "git_commit_hash 取得の DIP — 関数として分離すべき"} + ], + "nice_to_have": [ + {"id": "N1", "title": "独立サブコマンド設計は KISS 適合(維持)"}, + {"id": "N2", "title": "export_meta.json 分離は SRP 適合(維持)"}, + {"id": "N3", "title": "フラットアーカイブ構造の将来拡張時注意"}, + {"id": "N4", "title": "import_index.rs 命名は適切(維持)"}, + {"id": "N5", "title": "手動展開はセキュリティ上正当(維持)"} + ], + "must_fix_count": 2 +} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage2-apply-result.json b/dev-reports/issue/77/multi-stage-design-review/stage2-apply-result.json new file mode 100644 index 0000000..3c9101a --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage2-apply-result.json @@ -0,0 +1 @@ +{"stage": 2, "applied": ["M1: 定数参照の方針を明記(CLI層は commandindex_dir() ヘルパー使用)", "M2: StatusOptions ではなく verify: bool 引数追加に変更(CleanOptions パターン準拠)", "S1: エラー型に std::error::Error + From 実装の明記", "S2: Commands enum の doc comment を英語に統一", "S4: run() 戻り値を ExportResult/ImportResult に統一"]} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage2-review-context.json b/dev-reports/issue/77/multi-stage-design-review/stage2-review-context.json new file mode 100644 index 0000000..beffb09 --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage2-review-context.json @@ -0,0 +1,20 @@ +{ + "stage": 2, + "type": "整合性レビュー", + "must_fix": [ + {"id": "M1", "title": "COMMANDINDEX_DIR/INDEX_DIR_NAME 定数の重複 — 参照先統一が必要"}, + {"id": "M2", "title": "Status run() シグネチャ変更 — StatusOptions に format を含めるか CleanOptions パターンに合わせるか"} + ], + "should_fix": [ + {"id": "S1", "title": "ExportError/ImportError に std::error::Error + From 実装の明記"}, + {"id": "S2", "title": "Commands enum の doc comment を英語に統一"}, + {"id": "S3", "title": "import_index.rs の命名根拠明記(import は Rust 2024 で予約語でない)"}, + {"id": "S4", "title": "run() 戻り値を ExportResult/ImportResult 構造体に統一"} + ], + "nice_to_have": [ + {"id": "N1", "title": "snapshot.rs の indexer 配下配置は合理的"}, + {"id": "N2", "title": "テストファイル命名を cli_export.rs/cli_import.rs に"}, + {"id": "N3", "title": "verify の Status 同居は YAGNI 的に妥当"} + ], + "must_fix_count": 2 +} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage3-apply-result.json b/dev-reports/issue/77/multi-stage-design-review/stage3-apply-result.json new file mode 100644 index 0000000..7f60f31 --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage3-apply-result.json @@ -0,0 +1 @@ +{"stage": 3, "applied": ["M1: status.rs run() は verify: bool 引数追加(テスト修正は4箇所のみ)", "M2: import 後の update 整合性を統合テストで検証する旨を追記", "M3: export 前に tantivy 内パスが相対パスであることのバリデーション追加", "M4: export 前に symbols.db 内パスが相対パスであることのバリデーション追加", "S5: export 成果物は .commandindex/ 外に出力するため clean に影響なし"]} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage3-review-context.json b/dev-reports/issue/77/multi-stage-design-review/stage3-review-context.json new file mode 100644 index 0000000..4e5b47f --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage3-review-context.json @@ -0,0 +1,24 @@ +{ + "stage": 3, + "type": "影響分析レビュー", + "must_fix": [ + {"id": "M1", "title": "status.rs run() シグネチャ変更による既存5箇所の破壊的変更"}, + {"id": "M2", "title": "import 後の index_root 書き換えと update コマンドの整合性"}, + {"id": "M3", "title": "tantivy ドキュメント内 path が相対パスであることのバリデーション"}, + {"id": "M4", "title": "symbols.db 内パスが相対パスであることの保証"} + ], + "should_fix": [ + {"id": "S1", "title": "cli_args.rs help テストに export/import アサーション追加"}, + {"id": "S2", "title": "flate2 の pure Rust バックエンド確認"}, + {"id": "S3", "title": "embeddings.db のモデル互換性チェック"}, + {"id": "S4", "title": "export 後の clean/index による陳腐化検出"}, + {"id": "S5", "title": "clean コマンドの削除対象に export 成果物が未考慮"} + ], + "nice_to_have": [ + {"id": "N1", "title": "バイナリサイズ before/after 比較"}, + {"id": "N2", "title": "進捗表示の将来対応"}, + {"id": "N3", "title": "SymbolStore スキーマバージョン不整合のハンドリング"}, + {"id": "N4", "title": "snapshot.rs の indexer 配下配置の妥当性"} + ], + "must_fix_count": 4 +} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage4-apply-result.json b/dev-reports/issue/77/multi-stage-design-review/stage4-apply-result.json new file mode 100644 index 0000000..df7b9db --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage4-apply-result.json @@ -0,0 +1 @@ +{"stage": 4, "applied": ["M1: シンボリックリンク/ハードリンク拒否を validate_entry_type() として追加", "M2: canonicalize 記述を削除、文字列レベル+components()検証に一本化", "M3: 圧縮爆弾対策(サイズ上限1GB, エントリ数上限10000)を追加", "S1: ExportMeta から index_root 削除、state.json はサニタイズしてパック", "S3: ExportMeta に #[serde(deny_unknown_fields)] 追加"]} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage4-review-context.json b/dev-reports/issue/77/multi-stage-design-review/stage4-review-context.json new file mode 100644 index 0000000..baf8f87 --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage4-review-context.json @@ -0,0 +1,23 @@ +{ + "stage": 4, + "type": "セキュリティレビュー", + "must_fix": [ + {"id": "M1", "title": "シンボリックリンク/ハードリンクによるパストラバーサルが未対策"}, + {"id": "M2", "title": "canonicalize ベースの検証と擬似コードが矛盾 — 一本化が必要"}, + {"id": "M3", "title": "圧縮爆弾対策が未設計 — 展開サイズ/エントリ数の上限制御が必要"} + ], + "should_fix": [ + {"id": "S1", "title": "state.json/export_meta.json 内の絶対パス漏洩リスク"}, + {"id": "S2", "title": "ハードリンクエントリの処理方針未定義"}, + {"id": "S3", "title": "export_meta.json デシリアライズ後のバリデーション不足"}, + {"id": "S4", "title": "symbols.db のパス情報漏洩リスク注意書き"}, + {"id": "S5", "title": "commandindex.toml のエクスポート対象外明記"} + ], + "nice_to_have": [ + {"id": "N1", "title": "展開先ファイルのパーミッション固定化"}, + {"id": "N2", "title": ".gitignore に *.local.toml パターン追加"}, + {"id": "N3", "title": "アーカイブのチェックサム検証"}, + {"id": "N4", "title": "Unicode 正規化によるパス偽装対策"} + ], + "must_fix_count": 3 +} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage5-review-context.json b/dev-reports/issue/77/multi-stage-design-review/stage5-review-context.json new file mode 100644 index 0000000..ef79fc2 --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage5-review-context.json @@ -0,0 +1 @@ +{"stage": 5, "status": "skipped", "reason": "Codex サーバーエラーのためスキップ。Stage 1-4 で Must Fix 11件を全て反映済み。"} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage6-apply-result.json b/dev-reports/issue/77/multi-stage-design-review/stage6-apply-result.json new file mode 100644 index 0000000..2b453b3 --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage6-apply-result.json @@ -0,0 +1 @@ +{"stage": 6, "status": "skipped", "reason": "Stage 5 スキップのため"} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage7-review-context.json b/dev-reports/issue/77/multi-stage-design-review/stage7-review-context.json new file mode 100644 index 0000000..eaa74c4 --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage7-review-context.json @@ -0,0 +1 @@ +{"stage": 7, "status": "skipped", "reason": "Codex サーバーエラーのためスキップ。"} diff --git a/dev-reports/issue/77/multi-stage-design-review/stage8-apply-result.json b/dev-reports/issue/77/multi-stage-design-review/stage8-apply-result.json new file mode 100644 index 0000000..b42214e --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/stage8-apply-result.json @@ -0,0 +1 @@ +{"stage": 8, "status": "skipped", "reason": "Stage 7 スキップのため"} diff --git a/dev-reports/issue/77/multi-stage-design-review/summary-report.md b/dev-reports/issue/77/multi-stage-design-review/summary-report.md new file mode 100644 index 0000000..67326ba --- /dev/null +++ b/dev-reports/issue/77/multi-stage-design-review/summary-report.md @@ -0,0 +1,43 @@ +# Issue #77 マルチステージ設計レビュー サマリーレポート + +## 実施ステージ + +| Stage | 種別 | エージェント | Must Fix | Should Fix | Nice to Have | +|-------|------|-------------|----------|-----------|-------------| +| 1 | 設計原則 (SOLID/KISS/YAGNI/DRY) | Claude opus | 2 | 3 | 5 | +| 2 | 整合性 | Claude opus | 2 | 4 | 3 | +| 3 | 影響分析 | Claude opus | 4 | 5 | 4 | +| 4 | セキュリティ | Claude opus | 3 | 5 | 4 | +| 5-8 | 2回目 | Codex | スキップ(サーバーエラー) | - | - | + +## Must Fix 対応サマリー (11件) + +### 設計原則 (Stage 1) +1. **YAGNI**: StatusOptions 構造体 → verify: bool 引数に簡素化 +2. **SRP**: snapshot.rs の責務を ExportMeta 定義+読み書きに限定 + +### 整合性 (Stage 2) +3. **定数統一**: CLI層は commandindex_dir() ヘルパー、Indexer層は既存定数 +4. **パターン準拠**: status::run() は format 別引数のまま verify: bool 追加(CleanOptions パターン) + +### 影響分析 (Stage 3) +5. **破壊的変更最小化**: テスト修正は verify: false 追加の4箇所のみ +6. **import後整合性**: update コマンドとの整合性を統合テストで検証 +7. **相対パス検証**: export 前に tantivy ドキュメント内パスが相対パスであることを確認 +8. **相対パス検証**: export 前に symbols.db 内パスが相対パスであることを確認 + +### セキュリティ (Stage 4) +9. **シンボリックリンク**: validate_entry_type() でSymlink/Link エントリを即座に拒否 +10. **パス検証統一**: canonicalize() は使わず、文字列レベル+components()検証に一本化 +11. **圧縮爆弾対策**: 展開サイズ上限(1GB), エントリ数上限(10000) を追加 + +## 設計方針書の主要改善点 + +- ExportMeta から index_root を削除(情報漏洩防止) +- state.json のパック時サニタイズ +- ExportResult/ImportResult 構造体導入(既存パターン準拠) +- エラー型に std::error::Error + From 実装を明記 +- doc comment を英語に統一 +- テストファイル命名を既存パターンに統一 +- git hash 取得関数の分離(DIP, テスタビリティ) +- #[serde(deny_unknown_fields)] 追加 diff --git a/dev-reports/issue/77/pm-auto-dev/iteration-1/acceptance-result.json b/dev-reports/issue/77/pm-auto-dev/iteration-1/acceptance-result.json new file mode 100644 index 0000000..595e032 --- /dev/null +++ b/dev-reports/issue/77/pm-auto-dev/iteration-1/acceptance-result.json @@ -0,0 +1,7 @@ +{ + "status": "all_passed", + "passed_count": 14, + "failed_count": 0, + "added_tests": ["import_git_commit_hash_mismatch_shows_warning"], + "summary": "全14件の受け入れ基準をパス。AC-7のテストを1件追加。" +} diff --git a/dev-reports/issue/77/pm-auto-dev/iteration-1/codex-review-result.json b/dev-reports/issue/77/pm-auto-dev/iteration-1/codex-review-result.json new file mode 100644 index 0000000..36f5e8c --- /dev/null +++ b/dev-reports/issue/77/pm-auto-dev/iteration-1/codex-review-result.json @@ -0,0 +1 @@ +{"critical":[{"file":"src/cli/import_index.rs","line":179,"title":"`--force` import deletes the existing index before the archive is validated","detail":"`run()` removes `.commandindex/` immediately when `--force` is set, then starts parsing and extracting the archive. Any later failure such as malformed gzip/tar, incompatible `export_meta.json`, invalid `state.json`, or an I/O error leaves the user with the old index already destroyed. This is both a data-loss bug and a denial-of-service vector for untrusted archives.","recommendation":"Extract into a temporary directory, validate the archive contents completely, rewrite `state.json`, and only then replace the destination atomically. Keep the original `.commandindex/` untouched until validation succeeds."},{"file":"src/cli/import_index.rs","line":109,"title":"Windows path prefixes are not rejected during archive path validation","detail":"`validate_entry_path()` rejects absolute paths and `..`, but it does not reject `Component::Prefix` entries such as `C:foo` or UNC-prefixed paths on Windows. `target_dir.join(entry_path)` can therefore escape the intended destination on Windows even though `is_absolute()` is false, turning import into an arbitrary file write primitive on that platform.","recommendation":"Reject `Component::Prefix` explicitly in `validate_entry_path()`, and consider allowing only a strict set of relative normal components before joining with the destination path."}],"warnings":[{"file":"src/cli/import_index.rs","line":187,"title":"Failed imports can leave a partial `.commandindex/` behind","detail":"Cleanup is only performed for some error branches (`MAX_ENTRY_COUNT`, decompression limit, missing/incompatible `export_meta.json`). Errors from path traversal, symlink detection, `read_to_end`, `write`, `set_permissions`, or `serde_json::from_str(state.json)` return early without removing the partially extracted directory. Subsequent commands may observe a corrupted index layout.","recommendation":"Route all post-creation failures through a single cleanup path, or better, extract into a temporary directory and rename only on success."},{"file":"src/cli/status.rs","line":285,"title":"`status --verify` cannot report corrupted `state.json` because `run()` fails before verification runs","detail":"`run()` always loads `state.json` and checks its schema before it considers the `verify` flag. If `state.json` is missing or incompatible, `status --verify` exits with an error instead of returning the structured verification result produced by `run_verify()`. That weakens diagnostics exactly for the failure mode `--verify` is supposed to help investigate.","recommendation":"When `verify` is requested, execute the verification path first and report its findings even if `state.json` cannot be loaded for the normal status summary."}],"summary":"Review focused on `src/cli/export.rs`, `src/cli/import_index.rs`, `src/indexer/snapshot.rs`, `src/cli/status.rs`, and `src/main.rs`. Two fix-required issues were found in import: destructive `--force` behavior before validation and incomplete path validation on Windows. Additional warnings were found around partial extraction cleanup and `status --verify` diagnostics. No `unwrap()`/`expect()` panics or `unsafe` blocks were found in the reviewed production files.","requires_fix":true} diff --git a/dev-reports/issue/77/pm-auto-dev/iteration-1/refactor-result.json b/dev-reports/issue/77/pm-auto-dev/iteration-1/refactor-result.json new file mode 100644 index 0000000..3828f89 --- /dev/null +++ b/dev-reports/issue/77/pm-auto-dev/iteration-1/refactor-result.json @@ -0,0 +1,7 @@ +{ + "status": "success", + "changes": [ + "current_git_hash 関数を export.rs/import_index.rs から snapshot.rs に統合(DRY)", + "status.rs のハードコードパスを commandindex_dir() に統一(一貫性)" + ] +} diff --git a/dev-reports/issue/77/pm-auto-dev/iteration-1/tdd-context.json b/dev-reports/issue/77/pm-auto-dev/iteration-1/tdd-context.json new file mode 100644 index 0000000..80584e2 --- /dev/null +++ b/dev-reports/issue/77/pm-auto-dev/iteration-1/tdd-context.json @@ -0,0 +1,18 @@ +{ + "issue_number": 77, + "title": "インデックス共有モード(export/import/verify)", + "design_policy": "dev-reports/design/issue-77-design-policy.md", + "work_plan": "dev-reports/issue/77/work-plan.md", + "implementation_order": [ + "Task 1.1: ExportMeta構造体 (src/indexer/snapshot.rs)", + "Task 1.2: エラー型・結果型定義", + "Task 1.3: Cargo.toml依存追加 (tar, flate2)", + "Task 3.1: パストラバーサル検証ロジック", + "Task 2.1: エクスポートロジック (src/cli/export.rs)", + "Task 3.2: インポートロジック (src/cli/import_index.rs)", + "Task 4.1: status --verify 拡張", + "Task 5.1: main.rs + cli/mod.rs 統合", + "Task 5.2: 既存テスト修正", + "Task 6.1: E2Eテスト" + ] +} diff --git a/dev-reports/issue/77/pm-auto-dev/iteration-1/tdd-result.json b/dev-reports/issue/77/pm-auto-dev/iteration-1/tdd-result.json new file mode 100644 index 0000000..8581563 --- /dev/null +++ b/dev-reports/issue/77/pm-auto-dev/iteration-1/tdd-result.json @@ -0,0 +1,28 @@ +{ + "status": "success", + "new_files": [ + "src/indexer/snapshot.rs", + "src/cli/export.rs", + "src/cli/import_index.rs", + "tests/cli_export.rs", + "tests/cli_import.rs", + "tests/e2e_export_import.rs", + "tests/e2e_verify.rs" + ], + "modified_files": [ + "Cargo.toml", + "src/indexer/mod.rs", + "src/cli/mod.rs", + "src/cli/status.rs", + "src/main.rs", + "tests/cli_args.rs", + "tests/cli_status.rs" + ], + "test_count": 20, + "quality_checks": { + "build": "pass", + "clippy": "pass (0 warnings)", + "test": "pass (0 failures)", + "fmt": "pass (no diff)" + } +} diff --git a/dev-reports/issue/77/work-plan.md b/dev-reports/issue/77/work-plan.md new file mode 100644 index 0000000..00054fb --- /dev/null +++ b/dev-reports/issue/77/work-plan.md @@ -0,0 +1,207 @@ +# 作業計画書 - Issue #77: インデックス共有モード + +## Issue概要 + +**Issue番号**: #77 +**タイトル**: [Feature] インデックス共有モード(--shared / CI連携) +**サイズ**: L(新規サブコマンド2つ + status拡張 + セキュリティ実装) +**優先度**: Medium +**依存Issue**: #76 チーム共有設定ファイル(完了済み) + +## タスク分解 + +### Phase 1: 基盤(データ構造・型定義) + +#### Task 1.1: ExportMeta 構造体とスナップショットモジュール +- **成果物**: `src/indexer/snapshot.rs` +- **依存**: なし +- **内容**: + - `ExportMeta` 構造体(export_format_version, commandindex_version, git_commit_hash, exported_at) + - `#[serde(deny_unknown_fields)]` 付与 + - `EXPORT_FORMAT_VERSION` 定数(初期値: 1) + - `EXPORT_META_FILE` 定数("export_meta.json") + - `ExportMeta::save()` / `ExportMeta::load()` メソッド + - `src/indexer/mod.rs` に `pub mod snapshot;` 追加 +- **テスト**: ExportMeta のシリアライズ/デシリアライズ、deny_unknown_fields の検証 + +#### Task 1.2: エラー型定義 +- **成果物**: `src/cli/export.rs`(エラー型部分), `src/cli/import_index.rs`(エラー型部分) +- **依存**: Task 1.1 +- **内容**: + - `ExportError` enum + `fmt::Display` + `std::error::Error` + `From` 実装 + - `ImportError` enum(SymlinkDetected, DecompressionBomb 含む)+ 同様の実装 + - `ExportResult` / `ImportResult` 構造体 + - `ExportOptions` / `ImportOptions` 構造体 +- **テスト**: エラー型の Display 出力確認 + +#### Task 1.3: Cargo.toml 依存追加 +- **成果物**: `Cargo.toml` +- **依存**: なし +- **内容**: + - `tar = "0.4"` 追加 + - `flate2 = "1"` 追加(default features = miniz_oxide) +- **テスト**: `cargo build` 成功確認 + +### Phase 2: コア実装(エクスポート) + +#### Task 2.1: エクスポートロジック実装 +- **成果物**: `src/cli/export.rs` +- **依存**: Task 1.1, 1.2, 1.3 +- **内容**: + - `pub fn run(path: &Path, output: &Path, options: &ExportOptions) -> Result` + - `fn current_git_hash(repo_path: &Path) -> Option` ユーティリティ関数 + - .commandindex/ 存在確認 + - IndexState::load() でインデックス状態読み込み + - state.json の index_root サニタイズ(placeholder 置換してパック) + - tar::Builder + flate2::GzEncoder ストリーミング圧縮 + - config.local.toml 除外ロジック + - embeddings.db の --with-embeddings 制御 + - 出力ファイルサイズ計算 +- **テスト**: TDDで実装(テスト先行) + +#### Task 2.2: エクスポートテスト +- **成果物**: `tests/cli_export.rs` +- **依存**: Task 2.1 +- **内容**: + - export 基本動作テスト(インデックス作成 → export → アーカイブ内容検証) + - NotInitialized エラーテスト + - config.local.toml 除外検証 + - embeddings.db デフォルト除外検証 + - --with-embeddings 時の embeddings.db 含む検証 + +### Phase 3: コア実装(インポート) + +#### Task 3.1: パストラバーサル検証ロジック +- **成果物**: `src/cli/import_index.rs`(検証関数部分) +- **依存**: Task 1.2 +- **内容**: + - `fn validate_entry_path(entry_path: &Path, target_dir: &Path) -> Result` + - `fn validate_entry_type(entry: &tar::Entry) -> Result<(), ImportError>` + - 絶対パス拒否、`..` コンポーネント拒否 + - Symlink/Link エントリ拒否 + - 累積サイズ/エントリ数チェックロジック +- **テスト**: TDDで実装(パストラバーサル、シンボリックリンク、圧縮爆弾の各テストケース) + +#### Task 3.2: インポートロジック実装 +- **成果物**: `src/cli/import_index.rs` +- **依存**: Task 1.1, 1.2, 1.3, 3.1 +- **内容**: + - `pub fn run(path: &Path, archive: &Path, options: &ImportOptions) -> Result` + - アーカイブファイル存在確認 + - 既存 .commandindex/ チェック + --force 制御 + - ストリーミング展開(各エントリにセキュリティチェック適用) + - export_meta.json 読み込み + バージョン互換性チェック + - state.json の index_root 書き換え + - git hash 比較 + 不一致警告 + - tantivy インデックスオープン確認 + - パーミッション固定(0o644/0o755) +- **テスト**: TDDで実装 + +#### Task 3.3: インポートテスト +- **成果物**: `tests/cli_import.rs` +- **依存**: Task 3.2 +- **内容**: + - import 基本動作テスト + - 既存インデックスありで --force なしのエラー + - --force での上書きインポート + - パストラバーサル検出テスト(`../` パス) + - シンボリックリンクエントリ拒否テスト + - ハードリンクエントリ拒否テスト + - 圧縮爆弾検出テスト + - export_format_version 不一致エラー + - コミットハッシュ不一致警告 + +### Phase 4: Status --verify 拡張 + +#### Task 4.1: verify ロジック実装 +- **成果物**: `src/cli/status.rs`(変更) +- **依存**: なし(他 Phase と並行可能) +- **内容**: + - `run()` シグネチャに `verify: bool` 引数追加 + - `VerifyResult` / `VerifyIssue` / `VerifySeverity` 構造体追加 + - verify ロジック: state確認 → tantivy確認 → manifest確認 → symbols確認 + - Human/Json 両フォーマットでの verify 結果出力 +- **テスト**: TDDで実装 + +#### Task 4.2: verify テスト +- **成果物**: `tests/e2e_verify.rs` +- **依存**: Task 4.1 +- **内容**: + - 正常インデックスの verify パス + - 破損インデックスの verify エラー検出 + +### Phase 5: CLI統合 + +#### Task 5.1: main.rs + cli/mod.rs 統合 +- **成果物**: `src/main.rs`, `src/cli/mod.rs` +- **依存**: Task 2.1, 3.2, 4.1 +- **内容**: + - Commands enum に `Export` / `Import` バリアント追加(英語 doc comment) + - Status バリアントに `verify: bool` 追加 + - main.rs の match 分岐に Export / Import 処理追加 + - Status 分岐の run() 呼び出しに verify 引数追加 + - cli/mod.rs に `pub mod export;` `pub mod import_index;` 追加 + +#### Task 5.2: 既存テスト修正 +- **成果物**: `tests/cli_args.rs`, `tests/cli_status.rs` +- **依存**: Task 5.1 +- **内容**: + - cli_args.rs: help_flag_shows_usage に export/import 検証追加 + - cli_status.rs: run() 呼び出し3箇所に verify: false 追加 + +### Phase 6: E2Eテスト + +#### Task 6.1: export → import → search E2Eテスト +- **成果物**: `tests/e2e_export_import.rs` +- **依存**: Task 5.1 +- **内容**: + - インデックス作成 → export → import → search の完全フロー + - import 後に tantivy インデックスがオープンできることの確認 + - import 後に update が正常動作することの確認 + +### Phase 7: 品質チェック + +#### Task 7.1: 最終品質チェック +- **依存**: 全タスク完了 +- **内容**: + - `cargo build` エラー0件 + - `cargo clippy --all-targets -- -D warnings` 警告0件 + - `cargo test --all` 全テストパス + - `cargo fmt --all -- --check` 差分なし + +## タスク依存関係図 + +``` +Task 1.1 (ExportMeta) ──┐ +Task 1.2 (エラー型) ──┤ +Task 1.3 (Cargo.toml) ──┤ + ├──→ Task 2.1 (export) ──→ Task 2.2 (export テスト) + ├──→ Task 3.1 (パス検証) ──→ Task 3.2 (import) ──→ Task 3.3 (import テスト) + │ +Task 4.1 (verify) ───────┤──→ Task 4.2 (verify テスト) + │ + └──→ Task 5.1 (main.rs 統合) ──→ Task 5.2 (既存テスト修正) + ──→ Task 6.1 (E2E テスト) + ──→ Task 7.1 (品質チェック) +``` + +## TDD実装順序 + +1. **Task 1.1** → テスト: ExportMeta シリアライズ +2. **Task 1.2 + 1.3** → テスト: エラー型, ビルド確認 +3. **Task 3.1** → テスト: パストラバーサル検証(セキュリティテスト先行) +4. **Task 2.1 + 2.2** → テスト: export 基本動作 +5. **Task 3.2 + 3.3** → テスト: import 基本動作 + セキュリティ +6. **Task 4.1 + 4.2** → テスト: verify +7. **Task 5.1 + 5.2** → テスト: CLI統合 + 既存テスト修正 +8. **Task 6.1** → テスト: E2E +9. **Task 7.1** → 品質チェック + +## Definition of Done + +- [ ] すべてのタスクが完了 +- [ ] `cargo test --all` 全テストパス +- [ ] `cargo clippy --all-targets -- -D warnings` 警告0件 +- [ ] `cargo fmt --all -- --check` 差分なし +- [ ] セキュリティテスト(パストラバーサル、シンボリックリンク、圧縮爆弾)全パス +- [ ] E2E テスト(export → import → search)パス diff --git a/src/cli/export.rs b/src/cli/export.rs new file mode 100644 index 0000000..b077f04 --- /dev/null +++ b/src/cli/export.rs @@ -0,0 +1,196 @@ +use std::fmt; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use flate2::Compression; +use flate2::write::GzEncoder; +use tar::Builder; + +use crate::indexer::manifest::ManifestError; +use crate::indexer::snapshot::{ + EXPORT_FORMAT_VERSION, EXPORT_META_FILE, ExportMeta, current_git_hash, +}; +use crate::indexer::state::{IndexState, StateError}; + +/// Export options +pub struct ExportOptions { + pub with_embeddings: bool, +} + +/// Export result +#[derive(Debug)] +pub struct ExportResult { + pub output_path: PathBuf, + pub archive_size: u64, + pub git_commit_hash: Option, +} + +/// Export error +#[derive(Debug)] +pub enum ExportError { + NotInitialized, + Io(std::io::Error), + State(StateError), + Manifest(ManifestError), + Serialize(serde_json::Error), + GitError(String), +} + +impl fmt::Display for ExportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ExportError::NotInitialized => { + write!(f, "Index not initialized. Run `commandindex index` first.") + } + ExportError::Io(e) => write!(f, "IO error: {e}"), + ExportError::State(e) => write!(f, "State error: {e}"), + ExportError::Manifest(e) => write!(f, "Manifest error: {e}"), + ExportError::Serialize(e) => write!(f, "Serialization error: {e}"), + ExportError::GitError(msg) => write!(f, "Git error: {msg}"), + } + } +} + +impl std::error::Error for ExportError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ExportError::Io(e) => Some(e), + ExportError::State(e) => Some(e), + ExportError::Serialize(e) => Some(e), + _ => None, + } + } +} + +impl From for ExportError { + fn from(e: std::io::Error) -> Self { + ExportError::Io(e) + } +} + +impl From for ExportError { + fn from(e: StateError) -> Self { + ExportError::State(e) + } +} + +impl From for ExportError { + fn from(e: ManifestError) -> Self { + ExportError::Manifest(e) + } +} + +impl From for ExportError { + fn from(e: serde_json::Error) -> Self { + ExportError::Serialize(e) + } +} + +/// Placeholder for sanitized index_root in exported state.json +const INDEX_ROOT_PLACEHOLDER: &str = "__COMMANDINDEX_EXPORT_PLACEHOLDER__"; + +/// Export index as portable tar.gz archive +pub fn run( + path: &Path, + output: &Path, + options: &ExportOptions, +) -> Result { + let ci_dir = crate::indexer::commandindex_dir(path); + + // 1. Check .commandindex/ exists + if !IndexState::exists(&ci_dir) { + return Err(ExportError::NotInitialized); + } + + // 2. Load index state + let state = IndexState::load(&ci_dir)?; + state.check_schema_version()?; + + // 3. Get git commit hash + let git_hash = current_git_hash(path); + + // 4. Build ExportMeta + let meta = ExportMeta { + export_format_version: EXPORT_FORMAT_VERSION, + commandindex_version: env!("CARGO_PKG_VERSION").to_string(), + git_commit_hash: git_hash.clone(), + exported_at: Utc::now(), + }; + + // 5. Create tar.gz archive + let file = File::create(output)?; + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + + // 5a. Add export_meta.json first + let meta_json = serde_json::to_string_pretty(&meta)?; + add_bytes_to_tar(&mut builder, EXPORT_META_FILE, meta_json.as_bytes())?; + + // 5b. Add state.json with sanitized index_root + let mut sanitized_state = state.clone(); + sanitized_state.index_root = PathBuf::from(INDEX_ROOT_PLACEHOLDER); + let state_json = serde_json::to_string_pretty(&sanitized_state)?; + add_bytes_to_tar(&mut builder, "state.json", state_json.as_bytes())?; + + // 5c. Add manifest.json if it exists + let manifest_path = ci_dir.join("manifest.json"); + if manifest_path.exists() { + builder.append_path_with_name(&manifest_path, "manifest.json")?; + } + + // 5d. Add symbols.db if it exists + let symbols_path = ci_dir.join("symbols.db"); + if symbols_path.exists() { + builder.append_path_with_name(&symbols_path, "symbols.db")?; + } + + // 5e. Add tantivy/ directory recursively + let tantivy_dir = ci_dir.join("tantivy"); + if tantivy_dir.is_dir() { + builder.append_dir_all("tantivy", &tantivy_dir)?; + } + + // 5f. Add embeddings.db if --with-embeddings + if options.with_embeddings { + let embeddings_path = ci_dir.join("embeddings.db"); + if embeddings_path.exists() { + builder.append_path_with_name(&embeddings_path, "embeddings.db")?; + } + } + + // Note: config.local.toml is always excluded (not added) + + // 6. Finalize archive + let encoder = builder.into_inner()?; + encoder.finish()?; + + // 7. Get archive size + let archive_size = std::fs::metadata(output)?.len(); + + Ok(ExportResult { + output_path: output.to_path_buf(), + archive_size, + git_commit_hash: git_hash, + }) +} + +/// Helper to add bytes as a file entry to a tar archive +fn add_bytes_to_tar( + builder: &mut Builder, + name: &str, + data: &[u8], +) -> Result<(), std::io::Error> { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_mtime( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ); + header.set_cksum(); + builder.append_data(&mut header, name, data) +} diff --git a/src/cli/import_index.rs b/src/cli/import_index.rs new file mode 100644 index 0000000..54e4fd0 --- /dev/null +++ b/src/cli/import_index.rs @@ -0,0 +1,375 @@ +use std::fmt; +use std::fs::File; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +use flate2::read::GzDecoder; +use tar::Archive; + +use crate::indexer::snapshot::{ + EXPORT_FORMAT_VERSION, EXPORT_META_FILE, ExportMeta, current_git_hash, +}; +use crate::indexer::state::{IndexState, StateError}; + +/// Maximum cumulative decompressed size (1 GB) +const MAX_DECOMPRESS_SIZE: u64 = 1_073_741_824; + +/// Maximum number of entries in the archive +const MAX_ENTRY_COUNT: u64 = 10_000; + +/// Import options +pub struct ImportOptions { + pub force: bool, +} + +/// Import result +#[derive(Debug)] +pub struct ImportResult { + pub imported_files: u64, + pub git_hash_match: bool, + pub warnings: Vec, +} + +/// Import error +#[derive(Debug)] +pub enum ImportError { + Io(std::io::Error), + ExistingIndex(PathBuf), + PathTraversal(String), + SymlinkDetected(PathBuf), + InvalidArchive(String), + IncompatibleVersion { expected: u32, found: u32 }, + DecompressionBomb { limit: u64 }, + State(StateError), + Deserialize(serde_json::Error), +} + +impl fmt::Display for ImportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ImportError::Io(e) => write!(f, "IO error: {e}"), + ImportError::ExistingIndex(p) => { + write!( + f, + "Index already exists at {}. Use --force to overwrite.", + p.display() + ) + } + ImportError::PathTraversal(msg) => write!(f, "Path traversal detected: {msg}"), + ImportError::SymlinkDetected(p) => { + write!(f, "Symlink/hardlink entry detected: {}", p.display()) + } + ImportError::InvalidArchive(msg) => write!(f, "Invalid archive: {msg}"), + ImportError::IncompatibleVersion { expected, found } => { + write!( + f, + "Incompatible export format version: expected <= {expected}, found {found}" + ) + } + ImportError::DecompressionBomb { limit } => { + write!(f, "Decompression bomb: exceeded {limit} bytes limit") + } + ImportError::State(e) => write!(f, "State error: {e}"), + ImportError::Deserialize(e) => write!(f, "Deserialization error: {e}"), + } + } +} + +impl std::error::Error for ImportError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ImportError::Io(e) => Some(e), + ImportError::State(e) => Some(e), + ImportError::Deserialize(e) => Some(e), + _ => None, + } + } +} + +impl From for ImportError { + fn from(e: std::io::Error) -> Self { + ImportError::Io(e) + } +} + +impl From for ImportError { + fn from(e: StateError) -> Self { + ImportError::State(e) + } +} + +impl From for ImportError { + fn from(e: serde_json::Error) -> Self { + ImportError::Deserialize(e) + } +} + +/// Validate an entry path for path traversal attacks +fn validate_entry_path(entry_path: &Path, target_dir: &Path) -> Result { + // 1. Reject absolute paths + if entry_path.is_absolute() { + return Err(ImportError::PathTraversal(format!( + "absolute path: {}", + entry_path.display() + ))); + } + + // 2. Reject ".." and prefix components + for component in entry_path.components() { + match component { + Component::ParentDir => { + return Err(ImportError::PathTraversal(format!( + "parent dir: {}", + entry_path.display() + ))); + } + Component::Prefix(_) => { + return Err(ImportError::PathTraversal(format!( + "path prefix: {}", + entry_path.display() + ))); + } + _ => {} + } + } + + // 3. Build full path + let full_path = target_dir.join(entry_path); + + Ok(full_path) +} + +/// Validate entry type - reject symlinks and hardlinks +fn validate_entry_type(entry_type: tar::EntryType, entry_path: &Path) -> Result<(), ImportError> { + match entry_type { + tar::EntryType::Symlink | tar::EntryType::Link => { + Err(ImportError::SymlinkDetected(entry_path.to_path_buf())) + } + _ => Ok(()), + } +} + +/// Import index from tar.gz archive +pub fn run( + path: &Path, + archive: &Path, + options: &ImportOptions, +) -> Result { + let ci_dir = crate::indexer::commandindex_dir(path); + + // 1. Check archive exists + if !archive.exists() { + return Err(ImportError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Archive not found: {}", archive.display()), + ))); + } + + // 2. Check existing .commandindex/ + if ci_dir.exists() { + if !options.force { + return Err(ImportError::ExistingIndex(ci_dir.clone())); + } + // --force: verify not a symlink before removing + let metadata = std::fs::symlink_metadata(&ci_dir)?; + if metadata.file_type().is_symlink() { + return Err(ImportError::SymlinkDetected(ci_dir.clone())); + } + } + + // 3. Extract to a temporary directory first, then swap on success + let temp_dir = ci_dir.with_file_name(".commandindex_import_tmp"); + if temp_dir.exists() { + std::fs::remove_dir_all(&temp_dir)?; + } + std::fs::create_dir_all(&temp_dir)?; + + // 4. Extract archive into temp_dir with security checks + let result = extract_and_validate(path, archive, &temp_dir); + + match result { + Ok(import_result) => { + // All validation passed — atomically swap directories + if ci_dir.exists() { + std::fs::remove_dir_all(&ci_dir)?; + } + std::fs::rename(&temp_dir, &ci_dir)?; + Ok(import_result) + } + Err(e) => { + // Cleanup temp dir on any error + let _ = std::fs::remove_dir_all(&temp_dir); + Err(e) + } + } +} + +/// Extract archive contents into target directory and validate +fn extract_and_validate( + base_path: &Path, + archive: &Path, + target_dir: &Path, +) -> Result { + let file = File::open(archive)?; + let decoder = GzDecoder::new(file); + let mut tar_archive = Archive::new(decoder); + + let mut total_size: u64 = 0; + let mut entry_count: u64 = 0; + let mut imported_files: u64 = 0; + let mut warnings = Vec::new(); + + let entries = tar_archive + .entries() + .map_err(|e| ImportError::InvalidArchive(format!("Failed to read entries: {e}")))?; + + for entry_result in entries { + let mut entry = entry_result + .map_err(|e| ImportError::InvalidArchive(format!("Failed to read entry: {e}")))?; + + // Entry count check + entry_count += 1; + if entry_count > MAX_ENTRY_COUNT { + return Err(ImportError::DecompressionBomb { + limit: MAX_ENTRY_COUNT, + }); + } + + let entry_path = entry + .path() + .map_err(|e| ImportError::InvalidArchive(format!("Invalid path: {e}")))? + .to_path_buf(); + + let entry_type = entry.header().entry_type(); + + // Skip directory entries - we'll create dirs as needed + if entry_type == tar::EntryType::Directory { + continue; + } + + // Security: validate entry type (reject symlinks/hardlinks) + validate_entry_type(entry_type, &entry_path)?; + + // Security: validate path (reject traversal) + let full_path = validate_entry_path(&entry_path, target_dir)?; + + // Header size pre-check (early rejection, may be forged) + let header_size = entry.header().size().unwrap_or(0); + total_size = total_size.saturating_add(header_size); + if total_size > MAX_DECOMPRESS_SIZE { + return Err(ImportError::DecompressionBomb { + limit: MAX_DECOMPRESS_SIZE, + }); + } + + // Create parent directories + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Extract file with fixed permissions + let mut content = Vec::new(); + entry.read_to_end(&mut content)?; + + // Verify actual data size (header size may be forged) + let actual_size = content.len() as u64; + if actual_size > header_size { + // Re-adjust total_size with actual data + total_size = total_size + .saturating_sub(header_size) + .saturating_add(actual_size); + if total_size > MAX_DECOMPRESS_SIZE { + return Err(ImportError::DecompressionBomb { + limit: MAX_DECOMPRESS_SIZE, + }); + } + } + + std::fs::write(&full_path, &content)?; + + // Fix permissions (Unix only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = if full_path + .extension() + .is_some_and(|e| e == "sh" || e == "exe") + { + 0o755 + } else { + 0o644 + }; + std::fs::set_permissions(&full_path, std::fs::Permissions::from_mode(mode))?; + } + + imported_files += 1; + } + + // 5. Load and validate export_meta.json + let meta_path = target_dir.join(EXPORT_META_FILE); + if !meta_path.exists() { + return Err(ImportError::InvalidArchive( + "Missing export_meta.json".to_string(), + )); + } + + let meta = ExportMeta::load(&meta_path).map_err(|e| { + ImportError::InvalidArchive(format!("Failed to load export_meta.json: {e}")) + })?; + + // Version compatibility check (forward-compatible policy) + if meta.export_format_version > EXPORT_FORMAT_VERSION { + return Err(ImportError::IncompatibleVersion { + expected: EXPORT_FORMAT_VERSION, + found: meta.export_format_version, + }); + } + + // Validate commandindex_version length + if meta.commandindex_version.len() > 64 { + return Err(ImportError::InvalidArchive( + "commandindex_version too long".to_string(), + )); + } + + // 6. Rewrite state.json index_root to import target path + let state_path = target_dir.join("state.json"); + if state_path.exists() { + let state_content = std::fs::read_to_string(&state_path)?; + let mut state: IndexState = serde_json::from_str(&state_content)?; + + // Always set index_root to import target path + state.index_root = base_path.to_path_buf(); + state.save(target_dir)?; + } + + // 7. Check git hash match + let current_hash = current_git_hash(base_path); + let git_hash_match = match (&meta.git_commit_hash, ¤t_hash) { + (Some(export_hash), Some(current)) => { + if export_hash != current { + warnings.push(format!( + "Git commit hash mismatch: exported={}, current={}", + export_hash, current + )); + false + } else { + true + } + } + (Some(_), None) => { + warnings.push("Could not determine current git commit hash".to_string()); + false + } + (None, _) => { + // No hash recorded in export + true + } + }; + + Ok(ImportResult { + imported_files, + git_hash_match, + warnings, + }) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index fd9721e..389822e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,6 +2,8 @@ pub mod clean; pub mod config; pub mod context; pub mod embed; +pub mod export; +pub mod import_index; pub mod index; pub mod search; pub mod status; diff --git a/src/cli/status/mod.rs b/src/cli/status/mod.rs index 70f5f46..534bda3 100644 --- a/src/cli/status/mod.rs +++ b/src/cli/status/mod.rs @@ -8,7 +8,6 @@ use clap::ValueEnum; use serde::Serialize; use walkdir::WalkDir; -use crate::embedding::Config; use crate::embedding::store::EmbeddingStore; use crate::indexer::manifest::{FileType, Manifest}; use crate::indexer::state::{IndexState, StateError}; @@ -31,6 +30,7 @@ pub struct StatusOptions { pub detail: bool, pub coverage: bool, pub format: StatusFormat, + pub verify: bool, } impl Default for StatusOptions { @@ -39,6 +39,7 @@ impl Default for StatusOptions { detail: false, coverage: false, format: StatusFormat::Human, + verify: false, } } } @@ -233,11 +234,11 @@ fn get_embedding_file_count(base_path: &Path) -> u64 { } } -/// config.toml から embedding モデル名を取得する -fn get_embedding_model(commandindex_dir: &Path) -> Option { - match Config::load(commandindex_dir) { - Ok(Some(config)) => config.embedding.map(|e| e.model), - _ => None, +/// 設定から embedding モデル名を取得する +fn get_embedding_model(_commandindex_dir: &Path) -> Option { + match crate::config::load_config(Path::new(".")) { + Ok(config) => Some(config.embedding.model), + Err(_) => None, } } @@ -337,6 +338,67 @@ pub fn run( storage, }; + // Verify mode + if options.verify { + let verify_result = run_verify(path, &commandindex_dir); + match options.format { + StatusFormat::Human => { + writeln!(writer).ok(); + writeln!(writer, "Index Verification").ok(); + writeln!( + writer, + " State: {}", + if verify_result.state_valid { "OK" } else { "FAIL" } + ) + .ok(); + writeln!( + writer, + " Tantivy: {}", + if verify_result.tantivy_valid { + "OK" + } else { + "FAIL" + } + ) + .ok(); + writeln!( + writer, + " Manifest: {}", + if verify_result.manifest_valid { + "OK" + } else { + "FAIL" + } + ) + .ok(); + writeln!( + writer, + " Symbols: {}", + if verify_result.symbols_valid { + "OK" + } else { + "FAIL" + } + ) + .ok(); + for issue in &verify_result.issues { + writeln!( + writer, + " [{:?}] {}: {}", + issue.severity, issue.component, issue.message + ) + .ok(); + } + } + StatusFormat::Json => { + let json = serde_json::to_string_pretty(&verify_result) + .map_err(|e| StatusError::State(StateError::Json(e)))?; + writeln!(writer, "{json}").ok(); + } + } + return Ok(()); + } + match options.format { StatusFormat::Human => { // Basic info (always displayed) @@ -463,3 +525,141 @@ pub fn run( Ok(()) } + +// --------------------------------------------------------------------------- +// Verify +// --------------------------------------------------------------------------- + +/// 整合性チェック結果 +#[derive(Debug, Serialize)] +pub struct VerifyResult { + pub state_valid: bool, + pub tantivy_valid: bool, + pub manifest_valid: bool, + pub symbols_valid: bool, + pub issues: Vec, +} + +/// 個別のチェック結果 +#[derive(Debug, Serialize)] +pub struct VerifyIssue { + pub component: String, + pub severity: VerifySeverity, + pub message: String, +} + +/// 重要度 +#[derive(Debug, Serialize)] +pub enum VerifySeverity { + Error, + Warning, +} + +/// インデックスの整合性をチェックする +fn run_verify(base_path: &Path, commandindex_dir: &Path) -> VerifyResult { + let mut issues = Vec::new(); + + // 1. state.json + let state_valid = match IndexState::load(commandindex_dir) { + Ok(state) => match state.check_schema_version() { + Ok(()) => true, + Err(e) => { + issues.push(VerifyIssue { + component: "state".to_string(), + severity: VerifySeverity::Error, + message: format!("Schema version mismatch: {e}"), + }); + false + } + }, + Err(e) => { + issues.push(VerifyIssue { + component: "state".to_string(), + severity: VerifySeverity::Error, + message: format!("Failed to load: {e}"), + }); + false + } + }; + + // 2. tantivy + let tantivy_dir = crate::indexer::index_dir(base_path); + let tantivy_valid = if tantivy_dir.exists() { + match tantivy::Index::open_in_dir(&tantivy_dir) { + Ok(_) => true, + Err(e) => { + issues.push(VerifyIssue { + component: "tantivy".to_string(), + severity: VerifySeverity::Error, + message: format!("Failed to open: {e}"), + }); + false + } + } + } else { + issues.push(VerifyIssue { + component: "tantivy".to_string(), + severity: VerifySeverity::Error, + message: "Directory not found".to_string(), + }); + false + }; + + // 3. manifest.json + let manifest_valid = match Manifest::load(commandindex_dir) { + Ok(manifest) => { + let mut valid = true; + for entry in &manifest.files { + let file_path = base_path.join(&entry.path); + if !file_path.exists() { + issues.push(VerifyIssue { + component: "manifest".to_string(), + severity: VerifySeverity::Warning, + message: format!("File not found: {}", entry.path), + }); + valid = false; + } + } + valid + } + Err(e) => { + issues.push(VerifyIssue { + component: "manifest".to_string(), + severity: VerifySeverity::Error, + message: format!("Failed to load: {e}"), + }); + false + } + }; + + // 4. symbols.db + let db_path = crate::indexer::symbol_db_path(base_path); + let symbols_valid = if db_path.exists() { + match SymbolStore::open(&db_path) { + Ok(_) => true, + Err(e) => { + issues.push(VerifyIssue { + component: "symbols".to_string(), + severity: VerifySeverity::Error, + message: format!("Failed to open: {e}"), + }); + false + } + } + } else { + issues.push(VerifyIssue { + component: "symbols".to_string(), + severity: VerifySeverity::Warning, + message: "Database not found".to_string(), + }); + true // Not having symbols.db is not critical + }; + + VerifyResult { + state_valid, + tantivy_valid, + manifest_valid, + symbols_valid, + issues, + } +} diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs index 7733bcf..c7f0603 100644 --- a/src/indexer/mod.rs +++ b/src/indexer/mod.rs @@ -2,6 +2,7 @@ pub mod diff; pub mod manifest; pub mod reader; pub mod schema; +pub mod snapshot; pub mod state; pub mod symbol_store; pub mod writer; diff --git a/src/indexer/snapshot.rs b/src/indexer/snapshot.rs new file mode 100644 index 0000000..85c00f7 --- /dev/null +++ b/src/indexer/snapshot.rs @@ -0,0 +1,120 @@ +use std::path::Path; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Current export format version (integer increment, forward-compatible policy) +pub const EXPORT_FORMAT_VERSION: u32 = 1; + +/// File name for export metadata inside the archive +pub const EXPORT_META_FILE: &str = "export_meta.json"; + +/// Export metadata stored as the first entry in the archive +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExportMeta { + pub export_format_version: u32, + pub commandindex_version: String, + pub git_commit_hash: Option, + pub exported_at: DateTime, +} + +impl ExportMeta { + /// Save export metadata to a file + pub fn save(&self, path: &Path) -> Result<(), std::io::Error> { + let content = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; + std::fs::write(path, content) + } + + /// Load export metadata from a file + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let meta: Self = serde_json::from_str(&content) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + Ok(meta) + } +} + +/// Get current git HEAD commit hash for the repository at `repo_path`. +/// +/// Returns `None` if git is not available, the directory is not a git repository, +/// or the command fails for any reason. +pub fn current_git_hash(repo_path: &Path) -> Option { + std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(repo_path) + .output() + .ok() + .and_then(|output| { + if output.status.success() { + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_meta_roundtrip() { + let meta = ExportMeta { + export_format_version: EXPORT_FORMAT_VERSION, + commandindex_version: "0.0.5".to_string(), + git_commit_hash: Some("abc123".to_string()), + exported_at: Utc::now(), + }; + + let json = serde_json::to_string(&meta).unwrap(); + let deserialized: ExportMeta = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.export_format_version, EXPORT_FORMAT_VERSION); + assert_eq!(deserialized.commandindex_version, "0.0.5"); + assert_eq!(deserialized.git_commit_hash, Some("abc123".to_string())); + } + + #[test] + fn export_meta_none_git_hash() { + let meta = ExportMeta { + export_format_version: 1, + commandindex_version: "0.0.5".to_string(), + git_commit_hash: None, + exported_at: Utc::now(), + }; + + let json = serde_json::to_string(&meta).unwrap(); + let deserialized: ExportMeta = serde_json::from_str(&json).unwrap(); + assert!(deserialized.git_commit_hash.is_none()); + } + + #[test] + fn export_meta_deny_unknown_fields() { + let json = r#"{"export_format_version":1,"commandindex_version":"0.0.5","git_commit_hash":null,"exported_at":"2024-01-01T00:00:00Z","unknown_field":"value"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn export_meta_save_and_load() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("export_meta.json"); + + let meta = ExportMeta { + export_format_version: EXPORT_FORMAT_VERSION, + commandindex_version: "0.0.5".to_string(), + git_commit_hash: Some("def456".to_string()), + exported_at: Utc::now(), + }; + + meta.save(&path).unwrap(); + let loaded = ExportMeta::load(&path).unwrap(); + + assert_eq!(loaded.export_format_version, meta.export_format_version); + assert_eq!(loaded.commandindex_version, meta.commandindex_version); + assert_eq!(loaded.git_commit_hash, meta.git_commit_hash); + } +} diff --git a/src/main.rs b/src/main.rs index 95515df..bf7aff5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,9 @@ enum Commands { /// Show coverage statistics only #[arg(long, conflicts_with = "detail")] coverage: bool, + /// Verify index integrity + #[arg(long)] + verify: bool, }, /// Remove index and prepare for rebuild Clean { @@ -134,6 +137,22 @@ enum Commands { #[command(subcommand)] command: ConfigCommands, }, + /// Export index as portable tar.gz archive + Export { + /// Output file path (.tar.gz) + output: PathBuf, + /// Include embedding database + #[arg(long)] + with_embeddings: bool, + }, + /// Import index from tar.gz archive + Import { + /// Input archive file path (.tar.gz) + input: PathBuf, + /// Overwrite existing index + #[arg(long)] + force: bool, + }, } #[derive(Subcommand)] @@ -295,11 +314,13 @@ fn main() { format, detail, coverage, + verify, } => { let options = commandindex::cli::status::StatusOptions { detail, coverage, format, + verify, }; match commandindex::cli::status::run(&path, &options, &mut std::io::stdout()) { Ok(()) => 0, @@ -308,7 +329,7 @@ fn main() { 1 } } - } + }, Commands::Context { files, max_files, @@ -376,6 +397,53 @@ fn main() { } }, }, + Commands::Export { + output, + with_embeddings, + } => { + let options = commandindex::cli::export::ExportOptions { with_embeddings }; + match commandindex::cli::export::run(std::path::Path::new("."), &output, &options) { + Ok(result) => { + println!("Export completed:"); + println!(" Output: {}", result.output_path.display()); + println!( + " Size: {}", + commandindex::cli::status::format_size(result.archive_size) + ); + if let Some(hash) = &result.git_commit_hash { + println!(" Git commit: {hash}"); + } + 0 + } + Err(e) => { + eprintln!("Error: {e}"); + 1 + } + } + } + Commands::Import { input, force } => { + let options = commandindex::cli::import_index::ImportOptions { force }; + match commandindex::cli::import_index::run(std::path::Path::new("."), &input, &options) + { + Ok(result) => { + println!("Import completed:"); + println!(" Imported files: {}", result.imported_files); + if result.git_hash_match { + println!(" Git commit: matches"); + } else { + println!(" Git commit: mismatch"); + } + for warning in &result.warnings { + println!(" Warning: {warning}"); + } + 0 + } + Err(e) => { + eprintln!("Error: {e}"); + 1 + } + } + } }; process::exit(exit_code); diff --git a/tests/cli_args.rs b/tests/cli_args.rs index 8bc7d73..3ed40bf 100644 --- a/tests/cli_args.rs +++ b/tests/cli_args.rs @@ -15,7 +15,9 @@ fn help_flag_shows_usage() { .stdout(predicate::str::contains("status")) .stdout(predicate::str::contains("clean")) .stdout(predicate::str::contains("context")) - .stdout(predicate::str::contains("config")); + .stdout(predicate::str::contains("config")) + .stdout(predicate::str::contains("export")) + .stdout(predicate::str::contains("import")); } #[test] diff --git a/tests/cli_export.rs b/tests/cli_export.rs new file mode 100644 index 0000000..6843328 --- /dev/null +++ b/tests/cli_export.rs @@ -0,0 +1,165 @@ +mod common; + +use std::collections::HashSet; +use std::io::Read; + +use flate2::read::GzDecoder; +use tar::Archive; + +/// Helper: create an index and return the temp dir +fn setup_indexed_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("test.md"), "# Hello\n\nWorld\n").unwrap(); + common::run_index(dir.path()); + dir +} + +/// Helper: list file names in a tar.gz archive +fn list_archive_entries(archive_path: &std::path::Path) -> HashSet { + let file = std::fs::File::open(archive_path).unwrap(); + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let mut names = HashSet::new(); + for entry in archive.entries().unwrap() { + let entry = entry.unwrap(); + let path = entry.path().unwrap().to_string_lossy().to_string(); + names.insert(path); + } + names +} + +#[test] +fn export_basic() { + let dir = setup_indexed_dir(); + let output = dir.path().join("snapshot.tar.gz"); + + let options = commandindex::cli::export::ExportOptions { + with_embeddings: false, + }; + let result = commandindex::cli::export::run(dir.path(), &output, &options); + assert!(result.is_ok(), "export should succeed: {:?}", result.err()); + + let result = result.unwrap(); + assert!(result.output_path.exists()); + assert!(result.archive_size > 0); + + // Verify archive contents + let entries = list_archive_entries(&output); + assert!( + entries.contains("export_meta.json"), + "should contain export_meta.json" + ); + assert!(entries.contains("state.json"), "should contain state.json"); +} + +#[test] +fn export_not_initialized() { + let dir = tempfile::tempdir().expect("create temp dir"); + let output = dir.path().join("snapshot.tar.gz"); + + let options = commandindex::cli::export::ExportOptions { + with_embeddings: false, + }; + let result = commandindex::cli::export::run(dir.path(), &output, &options); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("not initialized")); +} + +#[test] +fn export_excludes_config_local_toml() { + let dir = setup_indexed_dir(); + + // Create a config.local.toml inside .commandindex + let ci_dir = dir.path().join(".commandindex"); + std::fs::write(ci_dir.join("config.local.toml"), "secret = 'value'\n").unwrap(); + + let output = dir.path().join("snapshot.tar.gz"); + let options = commandindex::cli::export::ExportOptions { + with_embeddings: false, + }; + commandindex::cli::export::run(dir.path(), &output, &options).unwrap(); + + let entries = list_archive_entries(&output); + assert!( + !entries.contains("config.local.toml"), + "config.local.toml should be excluded" + ); +} + +#[test] +fn export_excludes_embeddings_by_default() { + let dir = setup_indexed_dir(); + + // Create a fake embeddings.db inside .commandindex + let ci_dir = dir.path().join(".commandindex"); + std::fs::write(ci_dir.join("embeddings.db"), "fake-embeddings").unwrap(); + + let output = dir.path().join("snapshot.tar.gz"); + let options = commandindex::cli::export::ExportOptions { + with_embeddings: false, + }; + commandindex::cli::export::run(dir.path(), &output, &options).unwrap(); + + let entries = list_archive_entries(&output); + assert!( + !entries.contains("embeddings.db"), + "embeddings.db should be excluded by default" + ); +} + +#[test] +fn export_includes_embeddings_when_requested() { + let dir = setup_indexed_dir(); + + // Create a fake embeddings.db inside .commandindex + let ci_dir = dir.path().join(".commandindex"); + std::fs::write(ci_dir.join("embeddings.db"), "fake-embeddings").unwrap(); + + let output = dir.path().join("snapshot.tar.gz"); + let options = commandindex::cli::export::ExportOptions { + with_embeddings: true, + }; + commandindex::cli::export::run(dir.path(), &output, &options).unwrap(); + + let entries = list_archive_entries(&output); + assert!( + entries.contains("embeddings.db"), + "embeddings.db should be included with --with-embeddings" + ); +} + +#[test] +fn export_sanitizes_index_root() { + let dir = setup_indexed_dir(); + let output = dir.path().join("snapshot.tar.gz"); + + let options = commandindex::cli::export::ExportOptions { + with_embeddings: false, + }; + commandindex::cli::export::run(dir.path(), &output, &options).unwrap(); + + // Read state.json from the archive + let file = std::fs::File::open(&output).unwrap(); + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + let path = entry.path().unwrap().to_string_lossy().to_string(); + if path == "state.json" { + let mut content = String::new(); + entry.read_to_string(&mut content).unwrap(); + assert!( + content.contains("__COMMANDINDEX_EXPORT_PLACEHOLDER__"), + "state.json should have sanitized index_root" + ); + assert!( + !content.contains(dir.path().to_str().unwrap()), + "state.json should not contain original path" + ); + return; + } + } + panic!("state.json not found in archive"); +} diff --git a/tests/cli_import.rs b/tests/cli_import.rs new file mode 100644 index 0000000..c69f40a --- /dev/null +++ b/tests/cli_import.rs @@ -0,0 +1,323 @@ +mod common; + +use std::io::Write; + +use flate2::Compression; +use flate2::write::GzEncoder; +use tar::Builder; + +/// Helper: create an index, export it, and return (source_dir, archive_path) +fn setup_exported_archive() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("test.md"), "# Hello\n\nWorld\n").unwrap(); + common::run_index(dir.path()); + + let archive_path = dir.path().join("snapshot.tar.gz"); + let options = commandindex::cli::export::ExportOptions { + with_embeddings: false, + }; + commandindex::cli::export::run(dir.path(), &archive_path, &options).unwrap(); + (dir, archive_path) +} + +/// Helper: add a bytes entry to a tar builder +fn add_tar_entry(builder: &mut Builder, name: &str, data: &[u8]) { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_cksum(); + builder.append_data(&mut header, name, data).unwrap(); +} + +/// Helper: add a bytes entry with a raw path (bypasses tar safety for malicious paths) +fn add_tar_entry_raw(builder: &mut Builder, name: &[u8], data: &[u8]) { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_entry_type(tar::EntryType::Regular); + // Write the path directly into the header name field + { + let header_bytes = header.as_old_mut(); + let name_field = &mut header_bytes.name; + let len = name.len().min(name_field.len()); + name_field[..len].copy_from_slice(&name[..len]); + if len < name_field.len() { + name_field[len..].fill(0); + } + } + header.set_cksum(); + builder.append(&header, std::io::Cursor::new(data)).unwrap(); +} + +#[test] +fn import_basic() { + let (source_dir, archive_path) = setup_exported_archive(); + + // Clean existing index + common::run_clean(source_dir.path()); + + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(source_dir.path(), &archive_path, &options); + assert!(result.is_ok(), "import should succeed: {:?}", result.err()); + + let result = result.unwrap(); + assert!(result.imported_files > 0); + + // Verify index is usable (state exists) + let ci_dir = source_dir.path().join(".commandindex"); + assert!(ci_dir.join("state.json").exists()); +} + +#[test] +fn import_existing_index_without_force() { + let (source_dir, archive_path) = setup_exported_archive(); + + // Don't clean - existing index should cause error + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(source_dir.path(), &archive_path, &options); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("already exists")); +} + +#[test] +fn import_existing_index_with_force() { + let (source_dir, archive_path) = setup_exported_archive(); + + // With force, should overwrite + let options = commandindex::cli::import_index::ImportOptions { force: true }; + let result = commandindex::cli::import_index::run(source_dir.path(), &archive_path, &options); + assert!( + result.is_ok(), + "import --force should succeed: {:?}", + result.err() + ); +} + +#[test] +fn import_archive_not_found() { + let dir = tempfile::tempdir().expect("create temp dir"); + let fake_archive = dir.path().join("nonexistent.tar.gz"); + + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(dir.path(), &fake_archive, &options); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("not found") || err_msg.contains("Archive")); +} + +#[test] +fn import_rejects_path_traversal_parent_dir() { + let dir = tempfile::tempdir().expect("create temp dir"); + let archive_path = dir.path().join("malicious.tar.gz"); + + // Create a malicious archive with ../escape path + let file = std::fs::File::create(&archive_path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + + // Add valid export_meta.json + let meta = serde_json::json!({ + "export_format_version": 1, + "commandindex_version": "0.0.5", + "git_commit_hash": null, + "exported_at": "2024-01-01T00:00:00Z" + }); + add_tar_entry( + &mut builder, + "export_meta.json", + meta.to_string().as_bytes(), + ); + + // Add malicious path (using raw to bypass tar crate safety check) + add_tar_entry_raw(&mut builder, b"../../../etc/passwd", b"malicious content"); + + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); + + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(dir.path(), &archive_path, &options); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Path traversal") || err_msg.contains("parent dir"), + "Expected path traversal error, got: {err_msg}" + ); +} + +#[test] +fn import_rejects_symlink_entry() { + let dir = tempfile::tempdir().expect("create temp dir"); + let archive_path = dir.path().join("symlink.tar.gz"); + + // Create archive with a symlink entry + let file = std::fs::File::create(&archive_path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + + // Add export_meta.json + let meta = serde_json::json!({ + "export_format_version": 1, + "commandindex_version": "0.0.5", + "git_commit_hash": null, + "exported_at": "2024-01-01T00:00:00Z" + }); + add_tar_entry( + &mut builder, + "export_meta.json", + meta.to_string().as_bytes(), + ); + + // Add a symlink entry + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_mtime(0); + header.set_cksum(); + builder + .append_link(&mut header, "evil_link", "/etc/passwd") + .unwrap(); + + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); + + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(dir.path(), &archive_path, &options); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Symlink") || err_msg.contains("symlink"), + "Expected symlink error, got: {err_msg}" + ); +} + +#[test] +fn import_rejects_incompatible_version() { + let dir = tempfile::tempdir().expect("create temp dir"); + let archive_path = dir.path().join("future_version.tar.gz"); + + // Create archive with future format version + let file = std::fs::File::create(&archive_path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + + let meta = serde_json::json!({ + "export_format_version": 999, + "commandindex_version": "99.0.0", + "git_commit_hash": null, + "exported_at": "2024-01-01T00:00:00Z" + }); + add_tar_entry( + &mut builder, + "export_meta.json", + meta.to_string().as_bytes(), + ); + + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); + + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(dir.path(), &archive_path, &options); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("Incompatible") || err_msg.contains("version"), + "Expected version error, got: {err_msg}" + ); +} + +#[test] +fn import_git_commit_hash_mismatch_shows_warning() { + let dir = tempfile::tempdir().expect("create temp dir"); + let archive_path = dir.path().join("hash_mismatch.tar.gz"); + + // Create an archive with a known (fake) git_commit_hash + let file = std::fs::File::create(&archive_path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + + let meta = serde_json::json!({ + "export_format_version": 1, + "commandindex_version": "0.0.5", + "git_commit_hash": "aaaa0000bbbb1111cccc2222dddd3333eeee4444", + "exported_at": "2024-01-01T00:00:00Z" + }); + add_tar_entry( + &mut builder, + "export_meta.json", + meta.to_string().as_bytes(), + ); + + // Add a minimal state.json + let state = serde_json::json!({ + "version": "0.0.5", + "schema_version": 2, + "index_root": "__COMMANDINDEX_EXPORT_PLACEHOLDER__", + "created_at": "2024-01-01T00:00:00Z", + "last_updated_at": "2024-01-01T00:00:00Z", + "total_files": 0, + "total_sections": 0 + }); + add_tar_entry(&mut builder, "state.json", state.to_string().as_bytes()); + + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); + + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(dir.path(), &archive_path, &options); + assert!(result.is_ok(), "import should succeed: {:?}", result.err()); + + let result = result.unwrap(); + // The current git hash won't match the fake hash + assert!(!result.git_hash_match, "git_hash_match should be false"); + assert!( + !result.warnings.is_empty(), + "should have warnings about hash mismatch" + ); + // Check that the warning mentions the hash or mismatch + let has_relevant_warning = result + .warnings + .iter() + .any(|w| w.contains("mismatch") || w.contains("commit") || w.contains("hash")); + assert!( + has_relevant_warning, + "Expected warning about commit hash, got: {:?}", + result.warnings + ); +} + +#[test] +fn import_state_json_index_root_rewritten() { + let (_source_dir, archive_path) = setup_exported_archive(); + + // Import into a different directory + let import_dir = tempfile::tempdir().expect("create import dir"); + + let options = commandindex::cli::import_index::ImportOptions { force: false }; + let result = commandindex::cli::import_index::run(import_dir.path(), &archive_path, &options); + assert!(result.is_ok(), "import should succeed: {:?}", result.err()); + + // Read state.json and verify index_root was rewritten + let ci_dir = import_dir.path().join(".commandindex"); + let state_content = std::fs::read_to_string(ci_dir.join("state.json")).unwrap(); + let state: serde_json::Value = serde_json::from_str(&state_content).unwrap(); + + let index_root = state["index_root"].as_str().unwrap(); + assert!( + !index_root.contains("PLACEHOLDER"), + "index_root should not contain placeholder" + ); + + // Allow both original path and import path (depends on canonicalization) + // The key thing is that it shouldn't contain the placeholder + assert!(!index_root.is_empty(), "index_root should not be empty"); + + // Verify source dir's original path is NOT in the state + assert!( + !index_root.contains("__COMMANDINDEX_EXPORT_PLACEHOLDER__"), + "Should not contain placeholder" + ); +} diff --git a/tests/e2e_export_import.rs b/tests/e2e_export_import.rs new file mode 100644 index 0000000..feb2844 --- /dev/null +++ b/tests/e2e_export_import.rs @@ -0,0 +1,107 @@ +mod common; + +#[test] +fn e2e_export_import_search() { + // 1. Create source with markdown content + let source_dir = tempfile::tempdir().expect("create source dir"); + std::fs::write( + source_dir.path().join("readme.md"), + "# Project Overview\n\nThis is a test project for export import flow.\n", + ) + .unwrap(); + + // 2. Index the source + common::run_index(source_dir.path()); + + // 3. Verify search works on source + let results = common::run_search_jsonl(source_dir.path(), "test project"); + assert!(!results.is_empty(), "search should find results in source"); + + // 4. Export + let archive_path = source_dir.path().join("index-snapshot.tar.gz"); + let export_options = commandindex::cli::export::ExportOptions { + with_embeddings: false, + }; + let export_result = + commandindex::cli::export::run(source_dir.path(), &archive_path, &export_options).unwrap(); + assert!(export_result.archive_size > 0); + + // 5. Import into a new directory + let import_dir = tempfile::tempdir().expect("create import dir"); + // Copy the markdown file to the import dir (so relative paths work) + std::fs::write( + import_dir.path().join("readme.md"), + "# Project Overview\n\nThis is a test project for export import flow.\n", + ) + .unwrap(); + + let import_options = commandindex::cli::import_index::ImportOptions { force: false }; + let import_result = + commandindex::cli::import_index::run(import_dir.path(), &archive_path, &import_options) + .unwrap(); + assert!(import_result.imported_files > 0); + + // 6. Verify search works on imported index + let results = common::run_search_jsonl(import_dir.path(), "test project"); + assert!( + !results.is_empty(), + "search should find results after import" + ); + + // 7. Verify status works on imported index + let status = common::run_status_json(import_dir.path()); + assert!( + status.get("version").is_some(), + "status should show version after import" + ); +} + +#[test] +fn e2e_export_import_cli() { + // Test via CLI binary + let source_dir = tempfile::tempdir().expect("create source dir"); + std::fs::write( + source_dir.path().join("doc.md"), + "# Documentation\n\nSome content here.\n", + ) + .unwrap(); + + // Index + common::run_index(source_dir.path()); + + // Export via CLI + let archive_path = source_dir.path().join("export.tar.gz"); + common::cmd() + .args(["export", archive_path.to_str().unwrap()]) + .current_dir(source_dir.path()) + .assert() + .success(); + + assert!(archive_path.exists(), "archive should be created"); + + // Import via CLI into new dir + let import_dir = tempfile::tempdir().expect("create import dir"); + std::fs::write( + import_dir.path().join("doc.md"), + "# Documentation\n\nSome content here.\n", + ) + .unwrap(); + + common::cmd() + .args(["import", archive_path.to_str().unwrap()]) + .current_dir(import_dir.path()) + .assert() + .success(); + + // Verify status works + common::cmd() + .args([ + "status", + "--path", + import_dir.path().to_str().unwrap(), + "--format", + "json", + ]) + .assert() + .success(); +} diff --git a/tests/e2e_verify.rs b/tests/e2e_verify.rs new file mode 100644 index 0000000..3e03464 --- /dev/null +++ b/tests/e2e_verify.rs @@ -0,0 +1,82 @@ +mod common; + +use predicates::prelude::*; + +#[test] +fn verify_normal_index() { + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("test.md"), "# Test\n\nContent\n").unwrap(); + common::run_index(dir.path()); + + common::cmd() + .args(["status", "--path", dir.path().to_str().unwrap(), "--verify"]) + .assert() + .success() + .stdout(predicate::str::contains("Index Verification")) + .stdout(predicate::str::contains("State: OK")) + .stdout(predicate::str::contains("Tantivy: OK")); +} + +#[test] +fn verify_corrupted_index() { + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("test.md"), "# Test\n\nContent\n").unwrap(); + common::run_index(dir.path()); + + // Corrupt tantivy directory by removing it + let tantivy_dir = dir.path().join(".commandindex").join("tantivy"); + std::fs::remove_dir_all(&tantivy_dir).unwrap(); + + common::cmd() + .args(["status", "--path", dir.path().to_str().unwrap(), "--verify"]) + .assert() + .success() + .stdout(predicate::str::contains("Tantivy: FAIL")); +} + +#[test] +fn verify_json_format() { + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("test.md"), "# Test\n\nContent\n").unwrap(); + common::run_index(dir.path()); + + let output = common::cmd() + .args([ + "status", + "--path", + dir.path().to_str().unwrap(), + "--format", + "json", + "--verify", + ]) + .assert() + .success(); + + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid json"); + assert!( + parsed.get("state_valid").is_some(), + "should contain state_valid key" + ); + assert!( + parsed["state_valid"].as_bool().unwrap(), + "state should be valid" + ); + assert!( + parsed["tantivy_valid"].as_bool().unwrap(), + "tantivy should be valid" + ); +} + +#[test] +fn verify_without_flag_no_verify_output() { + let dir = tempfile::tempdir().expect("create temp dir"); + std::fs::write(dir.path().join("test.md"), "# Test\n\nContent\n").unwrap(); + common::run_index(dir.path()); + + common::cmd() + .args(["status", "--path", dir.path().to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains("Index Verification").not()); +}