---
phase: 04-release
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/cluster/ssh.rs
  - src/cluster/deploy.rs
  - Cargo.toml
autonomous: true
requirements: [PLAT-01, PLAT-02, PLAT-03, PLAT-04]
tags:
  - rust
  - security
  - bug-fix

# Revision note (2026-06-13)：根据 checker 反馈（task_completeness warning），
# 将 src/config/mod.rs 从 files_modified 移除：validate_safe_path 在 Task 2 中
# 明确标注为"可选 config 层白名单"，不属于 plan 的硬性产出。若 executor 选择实现
# 它，可在 SUMMARY 中记录为额外加固，但不计入本 plan 的完成度。

must_haves:
  truths:
    - "cargo test 全部通过（含新增的 5 个回归测试，覆盖 CR-01..CR-05）"
    - "sftp_write 调用 SFTP create() 而非 write()，可写入不存在的远端文件"
    - "expand_tilde 函数将 ~/ 前缀替换为 $HOME 路径，对绝对/相对路径透明返回"
    - "deploy.rs 中所有 install_path/data_path/instance_name 拼入 shell 命令前经过 shell_quote 单引号转义"
    - "TofuHandler::check_server_key 通过 tracing::warn! 记录服务器公钥指纹"
    - "安装包以 .bin 路径上传 + chmod +x 后按完整路径执行（不再依赖 PATH 上的 DMInstall.bin）"
    - "Cargo.toml [package] 含 description / license / repository 三个字段"
  artifacts:
    - path: "src/cluster/ssh.rs"
      provides: "expand_tilde + create-flag sftp_write + TOFU 指纹日志"
      contains: "fn expand_tilde"
    - path: "src/cluster/deploy.rs"
      provides: "shell_quote + .bin 上传执行链路"
      contains: "fn shell_quote"
    - path: "Cargo.toml"
      provides: "cargo-dist 必填 metadata"
      contains: "repository ="
  key_links:
    - from: "src/cluster/ssh.rs::try_key_auth"
      to: "expand_tilde"
      via: "本地函数调用"
      pattern: "expand_tilde\\(identity_file\\)"
    - from: "src/cluster/ssh.rs::SshSession::sftp_write"
      to: "russh_sftp::client::SftpSession::create"
      via: "create() + write_all() 调用替换原 sftp.write()"
      pattern: "sftp\\.create\\("
    - from: "src/cluster/deploy.rs::upload_installer_and_install"
      to: "远端 .bin 可执行路径"
      via: "sftp_write + chmod + 全路径调用"
      pattern: "\\.bin"
    - from: "src/cluster/deploy.rs::start_dmserver_mount / build_dminit_args / configure_database_role"
      to: "shell_quote"
      via: "格式化 shell 命令前统一调用 shell_quote"
      pattern: "shell_quote\\("
    - from: "src/cluster/ssh.rs::TofuHandler::check_server_key"
      to: "tracing::warn!"
      via: "记录 fingerprint 指纹"
      pattern: "tracing::warn!\\(\"\\[ssh\\]\\[TOFU\\]"
---

<objective>
**As a** Phase 4 发布流水线的执行者, **I want to** 修复 Phase 3 遗留的 5 个 Critical bug 并完善 Cargo.toml 发布元数据, **so that** 后续 cargo-dist init 可成功生成 release.yml，且发布的二进制实际可用于集群部署。

Purpose: 这五个 Critical 问题（SFTP CREATE 标志、ISO/bin 上传路径、tilde 展开、shell 注入、TOFU 指纹）让 cluster deploy 功能 100% 失败或存在严重安全漏洞，必须在发布前修复。Cargo.toml 的 repository/license/description 字段是 cargo-dist init 的硬性要求。

Output: 修复后的 ssh.rs / deploy.rs + 含完整 metadata 的 Cargo.toml + 5 个新增回归测试，cargo test 全绿。`src/config/mod.rs` 的 validate_safe_path 是可选加固，不在本 plan 硬性产出中（详见 Task 2 action 中的可选段）。

注：此 plan 不直接 cover PLAT-XX 需求（这些归属 Plan 02），但 Plan 02 的 cargo-dist init 强依赖本 plan 完成的 Cargo.toml metadata。
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-release/04-CONTEXT.md
@.planning/phases/04-release/04-RESEARCH.md
@.planning/phases/04-release/04-PATTERNS.md
@.planning/phases/04-release/04-VALIDATION.md
@.planning/phases/03-cluster/03-REVIEW.md
@src/cluster/ssh.rs
@src/cluster/deploy.rs
@src/install/silent_install.rs
@src/config/mod.rs
@Cargo.toml

<interfaces>
<!-- 关键类型与签名，executor 直接照用，避免再次探索代码库 -->

src/cluster/ssh.rs 现有公开签名：
```rust
pub trait CommandRunner: Send + Sync {
    async fn exec(&self, command: &str) -> Result<(Vec<u8>, u32), SshError>;
    async fn sftp_write(&self, remote_path: &str, bytes: &[u8]) -> Result<(), SshError>;
}

pub enum SshError {
    Connect { host: String, source: russh::Error },
    ExecFailed { command: String, exit_code: u32 },
    SftpUpload { remote_path: String, source: russh_sftp::client::error::Error },
}

pub struct TofuHandler {
    pub accepted_keys: std::sync::Mutex<Vec<russh::keys::PublicKey>>,
}

pub struct MockRunner { ... }   // 已实现 sftp_log() / exec_log() 测试辅助
```

russh-sftp 2.3.0 API（CR-02 修复要用）：
```rust
// SftpSession::create(remote_path) -> impl AsyncWrite + AsyncRead 文件句柄
// 配合 tokio::io::AsyncWriteExt::write_all 写入字节
use tokio::io::AsyncWriteExt;
let mut file = sftp.create(remote_path).await?;
file.write_all(bytes).await?;
```

russh 0.61.2 PublicKey 构造（CR-05 测试要用，已验证唯一确定路径）：
```rust
// russh::keys::PublicKey 是 ssh_key::PublicKey 的重导出
// （russh-0.61.2/src/keys/mod.rs:78: pub use ssh_key::{..., PublicKey};）
// ssh_key::PublicKey::from_openssh(&str) -> Result<Self> (ssh-key-0.7.0-rc.10/src/public.rs:131)
// 使用 known-good OpenSSH ED25519 公钥字符串构造，避免依赖随机数生成器或 rand 入口
let public_key = russh::keys::PublicKey::from_openssh(
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti test@example"
).unwrap();
let fingerprint = public_key.fingerprint(Default::default());
```

src/cluster/deploy.rs 现有公开函数（保持签名不变）：
```rust
pub fn build_dminit_args(node: &NodeConfig) -> Vec<String>;
pub async fn upload_installer_and_install(node: &NodeConfig, package_path: &Path, runner: &dyn CommandRunner) -> Result<()>;
pub async fn run_dminit_remote(node: &NodeConfig, runner: &dyn CommandRunner) -> Result<()>;
pub async fn distribute_configs(node: &NodeConfig, all_nodes: &[NodeConfig], oguid: u32, runner: &dyn CommandRunner) -> Result<()>;
pub async fn start_dmserver_mount(node: &NodeConfig, runner: &dyn CommandRunner) -> Result<()>;
pub async fn configure_database_role(node: &NodeConfig, role: NodeRole, oguid: u32, runner: &dyn CommandRunner) -> Result<()>;
pub async fn start_dmwatcher(node: &NodeConfig, runner: &dyn CommandRunner) -> Result<()>;
```

NodeConfig 关键字段（user-controlled，shell_quote 目标）：
```rust
// src/config/cluster.rs
pub struct NodeConfig {
    pub install_path: String,
    pub data_path: String,
    pub instance_name: String,
    // ... 其他字段
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: 修复 ssh.rs 的 SFTP CREATE 标志 + tilde 展开 + TOFU 指纹日志 (CR-02/CR-03/CR-05)</name>
  <files>src/cluster/ssh.rs</files>
  <read_first>
    - src/cluster/ssh.rs（当前实现：第 48-58 行 check_server_key、第 117-131 行 try_key_auth、第 151-176 行 sftp_write、第 281-337 行 tests 模块）
    - src/install/silent_install.rs（参考第 76-84 行 xml_escape 风格作为本地工具函数模板）
    - .planning/phases/04-release/04-PATTERNS.md（"src/cluster/ssh.rs — CR-02/CR-03/CR-05 三处修复"小节给出完整修复模板）
    - .planning/phases/03-cluster/03-REVIEW.md（CR-02/CR-03/CR-05 完整问题描述与修复方案）
  </read_first>
  <behavior>
    - Test 1 (CR-02): 一个新增的 #[tokio::test]，使用 MockRunner 验证 sftp_write 接受不存在的远端路径（断言：可以连续写入新路径不报错）— 注：因 MockRunner 不模拟真 SFTP，本测试以"代码编译通过 + 现有 distribute_configs 测试仍 pass"作为最小回归证明
    - Test 2 (CR-03): test_expand_tilde_replaces_home — 设置 HOME=/home/testuser，输入 PathBuf("~/.ssh/id_rsa")，期望返回 PathBuf("/home/testuser/.ssh/id_rsa")
    - Test 3 (CR-03): test_expand_tilde_no_tilde_unchanged — 输入 "/absolute/path/key"，期望原路径返回
    - Test 4 (CR-03): test_expand_tilde_missing_home_returns_input — 临时 unset HOME（或用 remove_var），输入 "~/foo"，期望原路径返回（不 panic）
    - Test 5 (CR-05): test_tofu_logs_fingerprint — 使用 `#[tracing_test::traced_test]` 标注（dev-deps 已含 tracing-test 0.2），通过 `russh::keys::PublicKey::from_openssh(...)` 解析一个 known-good OpenSSH ED25519 公钥字符串构造测试用 PublicKey，调用 `TofuHandler::check_server_key`，断言 tracing output 含 "[ssh][TOFU]" 字串
  </behavior>
  <action>
    在 src/cluster/ssh.rs 顶部 use 区域追加 `use tokio::io::AsyncWriteExt;`。

    **CR-02 修复 sftp_write（lines 151-177 范围）：**
    将 `sftp.write(remote_path, bytes).await.map_err(...)` 替换为两步：
    1. `let mut remote_file = sftp.create(remote_path).await.map_err(|source| SshError::SftpUpload { remote_path: remote_path.to_string(), source })?;`
    2. `remote_file.write_all(bytes).await.map_err(|io_err| SshError::SftpUpload { remote_path: remote_path.to_string(), source: russh_sftp::client::error::Error::UnexpectedBehavior(io_err.to_string()) })`
    返回 `Result<(), SshError>` 不变，函数签名保持。

    **CR-03 新增 expand_tilde 函数 + 在 try_key_auth 中调用：**
    在 `try_key_auth` 之前（line 117 前）添加私有函数 `fn expand_tilde(path: &std::path::PathBuf) -> std::path::PathBuf`，实现策略：
    - 取 `path.to_str()`，若失败则 clone 返回
    - `strip_prefix("~/")`；若不命中则 clone 返回
    - 读取 `std::env::var_os("HOME")`；若 None 则 clone 返回
    - 返回 `PathBuf::from(home).join(rest)`
    在 `try_key_auth` 函数内（line 122 调用 load_secret_key 处），用 `let expanded_path = expand_tilde(identity_file);` 然后传 `&expanded_path` 给 `load_secret_key`。

    **CR-05 修复 check_server_key（lines 48-58 范围）：**
    在 push 公钥之前，计算 `let fingerprint = server_public_key.fingerprint(Default::default());`，调用 `tracing::warn!("[ssh][TOFU] 接受服务器公钥（未验证）: {} — 生产环境请配置 host_key_fingerprint", fingerprint);`。同时把 `self.accepted_keys.lock().unwrap()` 改为 `match self.accepted_keys.lock() { Ok(mut accepted) => accepted.push(...), Err(poisoned) => poisoned.into_inner().push(...), }`（移除 unwrap，CLAUDE.md 风格）。

    **测试块：** 在现有 `#[cfg(test)] mod tests` 末尾追加上方 5 个测试。`test_tofu_logs_fingerprint` 使用 `#[tracing_test::traced_test]` 标注。

    **PublicKey 构造（唯一确定路径，无歧义）：**
    在测试中通过 `russh::keys::PublicKey::from_openssh(KNOWN_GOOD_PUBKEY)` 构造测试公钥，避免引入 `rand` 依赖或处理随机数生成器入口差异。具体使用 ssh-key crate 文档提供的示例 ED25519 公钥字符串：
    ```rust
    const TEST_PUBKEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti test@example";
    let public_key = russh::keys::PublicKey::from_openssh(TEST_PUBKEY).expect("解析 OpenSSH 公钥");
    ```
    路径已验证：`russh::keys::PublicKey` 是 `ssh_key::PublicKey` 的重导出（russh-0.61.2/src/keys/mod.rs:78），`PublicKey::from_openssh(&str) -> Result<Self>` 是 ssh-key-0.7.0-rc.10 公开 API（ssh-key-0.7.0-rc.10/src/public.rs:131）。**不要**使用 `PrivateKey::random()` 或引入 `rand` crate——本任务不向 Cargo.toml 添加任何依赖。

    构造 `TofuHandler` 后，调用其 `check_server_key(&public_key)`：
    ```rust
    let mut handler = TofuHandler {
        accepted_keys: std::sync::Mutex::new(Vec::new()),
    };
    handler.check_server_key(&public_key).await.unwrap();
    assert!(logs_contain("[ssh][TOFU]"));
    ```
    `tracing_test::traced_test` 宏提供 `logs_contain` 函数。

    不要修改 SshError 枚举或 CommandRunner trait 签名。函数长度遵循 CLAUDE.md ≤ 40 行约束。
  </action>
  <verify>
    <automated>cargo test --lib cluster::ssh::tests 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - 源断言：`grep -n "fn expand_tilde" src/cluster/ssh.rs` 返回一行，位于 try_key_auth 之前
    - 源断言：`grep -c "sftp\.create(" src/cluster/ssh.rs` ≥ 1（即原 sftp.write 已替换为 sftp.create）
    - 源断言：`grep -c "sftp\.write(remote_path, bytes)" src/cluster/ssh.rs` == 0（旧调用不残留；注意：搜索的是 sftp.write(path, bytes) 二参形式，create 后的 remote_file.write_all 不被命中）
    - 源断言：`grep -n "tracing::warn!" src/cluster/ssh.rs | grep "TOFU"` 至少返回一行
    - 源断言：`grep -n "expand_tilde(identity_file)" src/cluster/ssh.rs` 返回一行（位于 try_key_auth 内）
    - 源断言：`grep -c "PublicKey::from_openssh" src/cluster/ssh.rs` ≥ 1（test_tofu_logs_fingerprint 使用 from_openssh 构造）
    - 源断言：`grep -c "PrivateKey::random\|rand::thread_rng\|rand::rngs::OsRng" src/cluster/ssh.rs` == 0（确认未引入 rand 依赖路径）
    - 源断言：`grep -c ".unwrap()" src/cluster/ssh.rs | head -1` 中 check_server_key 内的 mutex unwrap 已消失（在 check_server_key 行附近 ±15 行无 .unwrap()）
    - 行为断言：`cargo test --lib cluster::ssh::tests::test_expand_tilde_replaces_home` 退出码 0
    - 行为断言：`cargo test --lib cluster::ssh::tests::test_expand_tilde_no_tilde_unchanged` 退出码 0
    - 行为断言：`cargo test --lib cluster::ssh::tests::test_tofu_logs_fingerprint` 退出码 0
    - 行为断言：`cargo test --lib cluster::ssh::tests` 整体退出码 0（既有 5 个测试 + 新增 4-5 个测试均 pass）
  </acceptance_criteria>
  <done>
    src/cluster/ssh.rs 编译通过且新增 4-5 个测试全部 pass；现有 test_mock_runner_matching / test_mock_runner_no_match_returns_127 / test_ssh_error_* 不破坏。
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: 修复 deploy.rs 的 .bin 上传执行链 + shell_quote 防注入 (CR-01/CR-04)</name>
  <files>src/cluster/deploy.rs</files>
  <read_first>
    - src/cluster/deploy.rs（当前实现：第 13-24 行 build_dminit_args、第 27-62 行 upload_installer_and_install、第 100-142 行 distribute_configs、第 145-156 行 start_dmserver_mount、第 159-187 行 configure_database_role、第 190-201 行 start_dmwatcher、第 203-343 行 tests）
    - src/config/mod.rs（现有 validate_install_config 第 95-118 行；本任务的"可选段"会读取此文件，若 executor 选择实现 validate_safe_path 则需修改）
    - .planning/phases/04-release/04-PATTERNS.md（"src/cluster/deploy.rs — CR-01/CR-04 两处修复" + "src/config/validate.rs — CR-04 路径字符集白名单校验（可选）"小节）
    - .planning/phases/03-cluster/03-REVIEW.md（CR-01/CR-04 完整问题描述）
  </read_first>
  <behavior>
    - Test 1 (CR-04): test_shell_quote_single_quotes_path — `shell_quote("/opt/dmdbms")` == `"'/opt/dmdbms'"`
    - Test 2 (CR-04): test_shell_quote_escapes_embedded_single_quote — `shell_quote("it's")` == `"'it'\\''s'"`（即 `'it'\\''s'`）
    - Test 3 (CR-04): test_shell_quote_blocks_injection — `shell_quote("/tmp; rm -rf /")` 返回字符串以 `'` 开头并整体包裹，含 `; rm -rf /` 但被单引号保护
    - Test 4 (CR-01): 修改既有 test_upload_installer_and_install_pushes_xml — 断言 `sftp_log` 含 `.bin` 路径而非 `.iso`（路径模式 `dm_installer_<instance>.bin`），且 exec_log 含 `chmod +x` 调用
    - Test 5 (CR-04): 新增 test_start_dmserver_mount_quotes_paths — start_dmserver_mount 执行后，exec_log 中至少有一条命令含 `'/opt/dmdbms'`（即 install_path 经过 shell_quote 包裹）
    - 可选 Test 6 (CR-04 config 层防御): test_validate_install_config_rejects_install_path_with_semicolon — 仅在 executor 选择实现 validate_safe_path 时新增；若不实现则跳过且不在 acceptance_criteria 中强制
  </behavior>
  <action>
    **必做部分（deploy.rs 内修复）：**

    **CR-01 修复 upload_installer_and_install（deploy.rs 第 27-62 行）：**
    - 把变量 `let remote_iso = format!("/tmp/dm_installer_{}.iso", node.instance_name);` 改名并改后缀为 `let remote_bin_path = format!("/tmp/dm_installer_{}.bin", node.instance_name);`
    - 对应 sftp_write 调用使用 `&remote_bin_path`
    - 在 sftp_write 之后、构造 install_cmd 之前，新增一次 runner.exec 调用：`runner.exec(&format!("chmod +x {}", shell_quote(&remote_bin_path))).await.map_err(|e| anyhow::anyhow!("chmod 安装包失败: {}", e))?;`
    - 修改 install_cmd 为 `format!("{} -q {}", shell_quote(&remote_bin_path), shell_quote(&remote_xml))`（不再依赖 `cd /tmp && DMInstall.bin`）

    **CR-04 新增 shell_quote 函数（deploy.rs 顶部，紧接 use 块之后）：**
    定义 `fn shell_quote(raw: &str) -> String { format!("'{}'", raw.replace('\'', "'\\''")) }`，附中文文档注释。

    **CR-04 在 deploy.rs 内所有 user-controlled 字符串拼入 shell 命令处调用 shell_quote：**
    - `build_dminit_args`：将 `format!("{}/bin/dminit", node.install_path)` 改为 `format!("{}/bin/dminit", shell_quote(&node.install_path))`；同理 `PATH={}` 用 `shell_quote(&node.data_path)`、`INSTANCE_NAME={}` 用 `shell_quote(&node.instance_name)`。其余数值类型字段（port/page_size/charset/case_sensitive/extent_size）不需要 shell_quote。
    - `distribute_configs` 的 `merge_cmd`（line 132-136）：`cat` 和 `>>` 两侧路径已由 `target_path` 拼接而来，给 `target_path` 结果整体 shell_quote。即 `format!("cat {} >> {}", shell_quote(&target_path(node, "dm.ini.cluster_suffix")), shell_quote(&target_path(node, "dm.ini")))`。
    - `start_dmserver_mount`（line 147-150）：先 `let install_path = shell_quote(&node.install_path); let data_path = shell_quote(&node.data_path); let instance_name = shell_quote(&node.instance_name);` 然后用占位符引用；注意 `/tmp/dmserver_<instance>.log` 中的 instance_name 也需要包裹（可拆为先 quote 再 format）。
    - `configure_database_role`（line 169-175）：`{}/bin/disql` 中 install_path 用 shell_quote 包裹；sql_block 在 echo "" 内已用双引号保护，但 oguid（u32）和 role_sql（编译期常量）安全，无需额外处理。
    - `start_dmwatcher`（line 192-194）：同 start_dmserver_mount 处理三个用户字段。

    **可选段（不影响本 plan 完成度）：CR-04 config 层白名单（src/config/mod.rs）**
    本可选段属于额外加固，**不在 files_modified 硬性产出列表中**。若 executor 选择实现：
    - 在 validate_install_config 末尾追加私有函数 `validate_safe_path(field_name: &str, value: &str) -> Result<()>`，规则：所有 char 必须满足 `is_alphanumeric()` 或在集合 `/\-_.` 中（注意正斜杠和反斜杠都允许，因 Windows 目标后续可能用）；不满足 bail 中文错误信息含字段名、非法值、白名单字符集。然后在 validate_install_config 末尾调用三次：`validate_safe_path("install_path", &cfg.install_path)?; validate_safe_path("data_path", &cfg.data_path)?; validate_safe_path("instance_name", &cfg.instance_name)?;`
    - 在 src/config/mod.rs tests 模块追加 Test 6。
    - 在 SUMMARY.md 中记录此可选加固已实现。
    若 executor 不实现：直接跳过；deploy.rs 内的 shell_quote 已是第一道防线，符合 plan 必做要求。

    **测试块：** 在 deploy.rs 现有 `#[cfg(test)] mod tests` 中追加 Test 1/2/3/5；修改既有 test_upload_installer_and_install_pushes_xml 为 Test 4 描述的形式（保留旧函数名以维持回归历史）。Test 6 仅在选择实现可选段时追加。

    保持所有函数 ≤ 40 行（CLAUDE.md 约束）；shell_quote 本身极短无需拆分。
  </action>
  <verify>
    <automated>cargo test --lib cluster::deploy::tests 2>&1 | tail -40</automated>
  </verify>
  <acceptance_criteria>
    - 源断言：`grep -n "fn shell_quote" src/cluster/deploy.rs` 返回一行
    - 源断言：`grep -c "\.iso\"" src/cluster/deploy.rs` == 0（旧 .iso 路径不残留；测试中检查 .iso 字符串可保留作为反向断言）
    - 源断言：`grep -c "remote_bin_path" src/cluster/deploy.rs` ≥ 2（变量定义 + 至少两处使用）
    - 源断言：`grep -c "chmod +x" src/cluster/deploy.rs` ≥ 1（含 chmod 步骤）
    - 源断言：`grep -c "shell_quote(&node\." src/cluster/deploy.rs` ≥ 6（build_dminit_args 三处 + start_dmserver_mount/start_dmwatcher 至少各一处）
    - 行为断言：`cargo test --lib cluster::deploy::tests::test_shell_quote_single_quotes_path` 退出码 0
    - 行为断言：`cargo test --lib cluster::deploy::tests::test_shell_quote_escapes_embedded_single_quote` 退出码 0
    - 行为断言：`cargo test --lib cluster::deploy::tests::test_start_dmserver_mount_quotes_paths` 退出码 0
    - 行为断言：修改后的 test_upload_installer_and_install_pushes_xml 验证 sftp_log 含 `.bin` 路径 + exec_log 含 chmod
    - 行为断言：`cargo test --lib` 整体退出码 0（不破坏 distribute_configs / configure_database_role / dminit_args 等既有测试）
    - 行为断言（仅当选择实现可选段）：`cargo test --lib config::tests::test_validate_install_config_rejects_install_path_with_semicolon` 退出码 0
  </acceptance_criteria>
  <done>
    deploy.rs 编译通过；新增 4 个测试（含 shell_quote 三例 + start_dmserver_mount 引用）+ 修改后的 upload installer 测试全部 pass；现有 7 个测试（含 build_dminit_args、distribute_configs、configure_database_role 主备两版、start_dmserver_mount mount/nohup 验证）保持 pass。可选 config 层 validate_safe_path 如已实现，则 SUMMARY 中标注。
  </done>
</task>

<task type="auto">
  <name>Task 3: 补齐 Cargo.toml 必填发布元数据（cargo-dist init 前置条件）</name>
  <files>Cargo.toml</files>
  <read_first>
    - Cargo.toml（当前 [package] 块第 1-4 行：name="dm-database-installer", version="0.1.0", edition="2024"，缺 description/license/repository）
    - .planning/phases/04-release/04-PATTERNS.md（"Cargo.toml — 新增 cargo-dist metadata + package 必填字段" 小节）
    - .planning/phases/04-release/04-RESEARCH.md（Pattern 4 + Pitfall 1：cargo-dist 需要的字段说明）
    - .planning/phases/04-release/04-CONTEXT.md（D-09 版本号管理策略；不引入 cargo-release）
  </read_first>
  <action>
    在 Cargo.toml 的 [package] 块内，紧接 `edition = "2024"` 之后追加三个字段（顺序与值如下）：
    - `description = "达梦数据库安装器 (dm-database-installer) — 单机/集群静默部署工具"`
    - `license = "MIT"`
    - `repository = "https://github.com/guangl/dm-database-installer"`

    不要在本任务中添加 `[workspace.metadata.dist]` 块或 `[profile.dist]`（这部分由 Plan 02 的 `cargo dist init` 命令生成）；本任务只完成 cargo-dist 的硬性前置条件。

    不要修改 `version`、`[[bin]]`、`[dependencies]`、`[dev-dependencies]` 任何字段。

    完成后运行 `cargo metadata --no-deps --format-version=1 | grep -o '"license":"[^"]*"' | head -1` 确认 license 字段已被 cargo 识别。
  </action>
  <verify>
    <automated>cargo metadata --no-deps --format-version=1 2>&1 | grep -oE '"(description|license|repository)":"[^"]*"' | sort -u</automated>
  </verify>
  <acceptance_criteria>
    - 源断言：`grep -c '^description = ' Cargo.toml` == 1
    - 源断言：`grep -c '^license = "MIT"' Cargo.toml` == 1
    - 源断言：`grep -c '^repository = "https://github.com/guangl/dm-database-installer"' Cargo.toml` == 1
    - 源断言：`grep -c '^\[workspace.metadata.dist\]' Cargo.toml` == 0（Plan 02 才添加此块，本任务不前置）
    - 行为断言：`cargo build --release 2>&1 | tail -5` 退出码 0（metadata 字段不应破坏编译）
    - 行为断言：`cargo metadata --no-deps --format-version=1` 输出中包含 description 与 license 字段
  </acceptance_criteria>
  <done>
    Cargo.toml 含 description / license / repository 三个 cargo-dist 必填字段，cargo build --release 通过。
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| TOML 配置文件 → Rust 进程 | 用户/DBA 编辑的 cluster.toml 经 serde 反序列化进入 NodeConfig；install_path/data_path/instance_name 是不可信输入 |
| Rust 进程 → 远端 shell（SSH 远端命令） | 通过 russh exec 把字符串提交到远端 bash；任何未转义字符可执行任意命令 |
| Rust 进程 → 远端 SFTP server | 通过 russh-sftp 上传字节；远端公钥未验证（TOFU） |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-01 | Tampering | src/cluster/deploy.rs 所有 format!("{}", node.install_path/data_path/instance_name) 拼入 shell 命令的位置 | mitigate | Task 2: 引入 shell_quote 单引号转义；所有 user-controlled 字段在拼接 shell 命令前调用 shell_quote(&node.field)；可选在 validate_install_config 加入路径字符集白名单 validate_safe_path |
| T-04-02 | Spoofing | src/cluster/ssh.rs::TofuHandler::check_server_key 静默接受任意公钥 | mitigate | Task 1: 记录 fingerprint warning（tracing::warn!），便于运维比对；完整 known_hosts 验证留 v2 |
| T-04-03 | DenialOfService | src/cluster/ssh.rs::sftp_write 使用 SFTP write flag（无 CREATE）导致 distribute_configs 100% 失败 | mitigate | Task 1: 改用 sftp.create() + AsyncWriteExt::write_all，正确创建新文件 |
| T-04-04 | DenialOfService | src/cluster/deploy.rs::upload_installer_and_install 上传 ISO 后直接调用 DMInstall.bin（远端 PATH 上不存在） | mitigate | Task 2: 改为上传 `.bin` 完整可执行文件 + chmod +x + 按完整路径调用 |
| T-04-05 | InformationDisclosure | src/cluster/ssh.rs::try_key_auth 中 `~/.ssh/id_rsa` 字面路径解析为不存在文件，silently fallback 到密码认证 | mitigate | Task 1: expand_tilde 函数展开 `~/` 前缀；保留 silently fallback 行为但减少误触发（WR-03 升级留后续 plan） |
| T-04-SC | Tampering | npm/pip/cargo installs（本 plan 不引入任何新 crate） | accept | 本 plan 不向 Cargo.toml [dependencies] 增加任何新依赖；只追加 [package] 字段。无需 slopcheck |
</threat_model>

<verification>
- `cargo test --lib 2>&1 | tail -20` 整体退出码 0；新增测试约 8 个全部 pass，现有测试无回归
- `cargo build --release 2>&1 | tail -5` 退出码 0（Cargo.toml metadata 改动不影响编译）
- `cargo metadata --no-deps --format-version=1 | grep -E '"(description|license|repository)"' | wc -l` ≥ 3
- 源码扫描：deploy.rs 中所有 `node.install_path`、`node.data_path`、`node.instance_name` 在 format!/exec 调用链路上均被 `shell_quote(&...)` 包裹；可用 `grep -nE "format!\(.*node\.(install_path|data_path|instance_name)" src/cluster/deploy.rs` 复查每条命中行均含 shell_quote
</verification>

<success_criteria>
- 五个 Critical bug（CR-01..CR-05）全部修复，每个对应至少一个新增或修改的回归测试
- src/cluster/ssh.rs 含 expand_tilde 函数 + sftp.create() 路径 + TOFU 指纹 warning
- src/cluster/deploy.rs 含 shell_quote 函数 + .bin 上传执行链路 + 所有 user-controlled 字符串经 shell_quote 包裹
- Cargo.toml [package] 含 description / license / repository（cargo-dist init 不再因 metadata 缺失报错）
- cargo test 全绿，cargo build --release 通过
</success_criteria>

<output>
Create `.planning/phases/04-release/04-01-SUMMARY.md` when done. SUMMARY 应记录：
- 五个 CR-XX 各自的修复点（文件:行 范围）
- 新增的测试列表与命名
- Cargo.toml 新增字段
- 给 Plan 02 的接口约定：Cargo.toml metadata 已就绪，cargo dist init 可直接运行
- 是否选择实现可选的 validate_safe_path（config 层白名单）；若实现，列出 mod.rs 修改的行范围与新增测试
</output>
</content>
