Skip to main content

codex_sync/sync/
ssh.rs

1//! SSH transport adapter. Codex-aware merging lives in the sibling `codex` module.
2
3use super::codex;
4use anyhow::{Context, Result, bail};
5use std::{
6    fs,
7    path::PathBuf,
8    process::Command,
9    time::{SystemTime, UNIX_EPOCH},
10};
11
12pub use codex::MergeReport as SshReport;
13
14pub fn push(host: &str, codex_home: Option<PathBuf>, mirror: bool) -> Result<SshReport> {
15    validate_host(host)?;
16    let home = dirs::home_dir().context("无法确定本机主目录")?;
17    let codex_home = codex_home.unwrap_or_else(|| home.join(".codex"));
18    if !codex_home.is_dir() {
19        bail!("本机 Codex 目录不存在:{}", codex_home.display());
20    }
21    ensure_command("ssh")?;
22    ensure_command("scp")?;
23
24    let transfer_id = format!("{}-{}", hostname::get()?.to_string_lossy(), now());
25    let temporary = std::env::temp_dir().join(format!("codex-sync-ssh-{transfer_id}"));
26    let bundle = temporary.join("bundle");
27    fs::create_dir_all(&bundle)?;
28    let result = (|| -> Result<SshReport> {
29        codex::create_bundle(&codex_home, &bundle)?;
30        let remote_relative = format!(".codex-sync/incoming/{transfer_id}");
31        run(
32            Command::new("ssh")
33                .arg(host)
34                .arg("mkdir")
35                .arg("-p")
36                .arg(&remote_relative),
37            "创建远端接收目录",
38        )?;
39        run(
40            Command::new("scp")
41                .arg("-r")
42                .arg(format!("{}/.", bundle.display()))
43                .arg(format!("{host}:{remote_relative}/")),
44            "上传本机会话包",
45        )?;
46
47        let mirror_flag = if mirror { " --mirror" } else { "" };
48        let remote_command = format!(
49            "bin=$(command -v codex-sync || true); if [ -z \"$bin\" ] && [ -x \"$HOME/.cargo/bin/codex-sync\" ]; then bin=\"$HOME/.cargo/bin/codex-sync\"; fi; if [ -z \"$bin\" ]; then echo '远端未安装 codex-sync 0.4.0+' >&2; exit 127; fi; \"$bin\" ssh receive --bundle \"$HOME/{remote_relative}\"{mirror_flag}"
50        );
51        let output = Command::new("ssh")
52            .arg(host)
53            .arg(remote_command)
54            .output()
55            .context("调用远端 codex-sync")?;
56        if !output.status.success() {
57            bail!(
58                "远端合并失败:{}",
59                String::from_utf8_lossy(&output.stderr).trim()
60            );
61        }
62        let stdout = String::from_utf8(output.stdout)?;
63        let serialized = stdout
64            .lines()
65            .find_map(|line| line.strip_prefix("CODEX_SYNC_REPORT="))
66            .context("远端未返回合并报告")?;
67        let report = serde_json::from_str(serialized)?;
68
69        let cleanup = format!("rm -rf \"$HOME/{remote_relative}\"");
70        run(
71            Command::new("ssh").arg(host).arg(cleanup),
72            "清理远端临时接收包",
73        )?;
74        Ok(report)
75    })();
76    let _ = fs::remove_dir_all(temporary);
77    result
78}
79
80pub fn receive(
81    bundle: &std::path::Path,
82    codex_home: Option<PathBuf>,
83    mirror: bool,
84) -> Result<SshReport> {
85    let home = dirs::home_dir().context("无法确定远端主目录")?;
86    let codex_home = codex_home.unwrap_or_else(|| home.join(".codex"));
87    codex::apply_bundle(bundle, &codex_home, mirror)
88}
89
90fn validate_host(host: &str) -> Result<()> {
91    if host.is_empty()
92        || !host
93            .chars()
94            .all(|character| character.is_ascii_alphanumeric() || "@._-".contains(character))
95    {
96        bail!("非法 SSH 主机名");
97    }
98    Ok(())
99}
100
101fn ensure_command(name: &str) -> Result<()> {
102    if Command::new(name).arg("-V").output().is_err() {
103        bail!("系统缺少 {name} 命令");
104    }
105    Ok(())
106}
107
108fn run(command: &mut Command, action: &str) -> Result<()> {
109    let status = command.status().with_context(|| action.to_owned())?;
110    if !status.success() {
111        bail!("{action}失败,退出码:{status}");
112    }
113    Ok(())
114}
115
116fn now() -> u64 {
117    SystemTime::now()
118        .duration_since(UNIX_EPOCH)
119        .unwrap_or_default()
120        .as_secs()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn host_validation_blocks_shell_syntax() {
129        assert!(validate_host("lty").is_ok());
130        assert!(validate_host("user@example.local").is_ok());
131        assert!(validate_host("lty;rm -rf /").is_err());
132    }
133}