codex-sync 0.3.0

Sync and merge Codex conversations across computers, LAN, SSH, and offline storage
use crate::config::Config;
use anyhow::{Context, Result, bail};
use reqwest::header::AUTHORIZATION;
use serde::{Deserialize, Serialize};
use std::{
    collections::HashSet,
    fs,
    io::Write,
    path::{Component, Path, PathBuf},
};
use walkdir::WalkDir;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
    pub path: String,
    pub size: u64,
    pub modified: u64,
    pub hash: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
    pub node_id: String,
    pub node_name: String,
    pub files: Vec<FileInfo>,
}

#[derive(Debug, Default, Serialize)]
pub struct PullReport {
    pub downloaded: usize,
    pub skipped: usize,
    pub conflicts: usize,
}

pub fn conflict_path(target: &Path, node_name: &str) -> PathBuf {
    target.with_extension(format!(
        "{}.conflict-{}",
        target
            .extension()
            .and_then(|x| x.to_str())
            .unwrap_or("file"),
        sanitize_name(node_name)
    ))
}

fn sanitize_name(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

fn excluded(cfg: &Config, rel: &Path) -> bool {
    rel.components()
        .any(|c| cfg.exclude.iter().any(|x| c.as_os_str() == x.as_str()))
}

pub fn safe_path(root: &Path, rel: &str) -> Result<PathBuf> {
    let path = Path::new(rel);
    if path.is_absolute()
        || path
            .components()
            .any(|c| !matches!(c, Component::Normal(_)))
    {
        bail!("非法路径");
    }
    Ok(root.join(path))
}

pub fn manifest(cfg: &Config) -> Result<Manifest> {
    let mut files = Vec::new();
    if !cfg.sync_dir.exists() {
        fs::create_dir_all(&cfg.sync_dir)?;
    }
    for entry in WalkDir::new(&cfg.sync_dir)
        .follow_links(false)
        .into_iter()
        .filter_map(Result::ok)
    {
        if !entry.file_type().is_file() {
            continue;
        }
        let rel = entry.path().strip_prefix(&cfg.sync_dir)?;
        if excluded(cfg, rel) {
            continue;
        }
        if files.len() >= cfg.max_files {
            bail!("同步文件数量超过上限 {}", cfg.max_files);
        }
        let meta = entry.metadata()?;
        if meta.len() > cfg.max_file_size_bytes {
            bail!(
                "同步文件超过大小上限 {}:{}",
                cfg.max_file_size_bytes,
                entry.path().display()
            );
        }
        let bytes = fs::read(entry.path())?;
        let modified = meta
            .modified()?
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        files.push(FileInfo {
            path: rel.to_string_lossy().replace('\\', "/"),
            size: meta.len(),
            modified,
            hash: blake3::hash(&bytes).to_hex().to_string(),
        });
    }
    files.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(Manifest {
        node_id: cfg.node_id(),
        node_name: cfg.node_name.clone(),
        files,
    })
}

pub async fn pull(cfg: &Config, peer: &str, overwrite: bool) -> Result<PullReport> {
    let base = if peer.starts_with("http") {
        peer.trim_end_matches('/').to_string()
    } else {
        format!("http://{}", peer.trim_end_matches('/'))
    };
    let client = reqwest::Client::new();
    let auth = format!("Bearer {}", cfg.shared_key);
    let remote: Manifest = client
        .get(format!("{base}/api/manifest"))
        .header(AUTHORIZATION, &auth)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;
    validate_manifest(cfg, &remote)?;
    let local = manifest(cfg)?;
    let local_map: std::collections::HashMap<_, _> = local
        .files
        .into_iter()
        .map(|f| (f.path.clone(), f))
        .collect();
    let mut report = PullReport::default();
    for file in remote.files {
        if local_map
            .get(&file.path)
            .is_some_and(|f| f.hash == file.hash)
        {
            report.skipped += 1;
            continue;
        }
        let target = safe_path(&cfg.sync_dir, &file.path)?;
        if target.exists() && !overwrite {
            let conflict = conflict_path(&target, &remote.node_name);
            download(
                &client,
                &auth,
                &base,
                &file,
                &conflict,
                cfg.max_file_size_bytes,
            )
            .await?;
            report.conflicts += 1;
        } else {
            download(
                &client,
                &auth,
                &base,
                &file,
                &target,
                cfg.max_file_size_bytes,
            )
            .await?;
            report.downloaded += 1;
        }
    }
    Ok(report)
}

async fn download(
    client: &reqwest::Client,
    auth: &str,
    base: &str,
    file: &FileInfo,
    target: &Path,
    max_file_size: u64,
) -> Result<()> {
    let url = format!(
        "{base}/api/file/{}",
        file.path
            .split('/')
            .map(url_encode)
            .collect::<Vec<_>>()
            .join("/")
    );
    let mut response = client
        .get(url)
        .header(AUTHORIZATION, auth)
        .send()
        .await?
        .error_for_status()?;
    if response
        .content_length()
        .is_some_and(|size| size > max_file_size || size != file.size)
    {
        bail!("远端文件大小不符合清单:{}", file.path);
    }
    let mut bytes = Vec::with_capacity(file.size.min(max_file_size) as usize);
    while let Some(chunk) = response.chunk().await? {
        if bytes.len().saturating_add(chunk.len()) > max_file_size as usize {
            bail!("远端文件超过大小上限:{}", file.path);
        }
        bytes.extend_from_slice(&chunk);
    }
    if bytes.len() as u64 != file.size || blake3::hash(&bytes).to_hex().as_str() != file.hash {
        bail!("远端文件校验失败:{}", file.path);
    }
    if let Some(parent) = target.parent() {
        fs::create_dir_all(parent)?;
    }
    if target
        .symlink_metadata()
        .is_ok_and(|meta| meta.file_type().is_symlink())
    {
        bail!("拒绝覆盖符号链接:{}", target.display());
    }
    let temp = temporary_path(target);
    let mut output = fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&temp)
        .with_context(|| format!("写入 {}", temp.display()))?;
    output.write_all(&bytes)?;
    output.sync_all()?;
    fs::rename(temp, target)?;
    Ok(())
}

fn validate_manifest(cfg: &Config, manifest: &Manifest) -> Result<()> {
    if manifest.files.len() > cfg.max_files {
        bail!("远端同步文件数量超过上限 {}", cfg.max_files);
    }
    let mut paths = HashSet::new();
    for file in &manifest.files {
        safe_path(&cfg.sync_dir, &file.path)?;
        if file.size > cfg.max_file_size_bytes {
            bail!("远端文件超过大小上限:{}", file.path);
        }
        if file.hash.len() != 64 || !file.hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
            bail!("远端文件哈希格式无效:{}", file.path);
        }
        if !paths.insert(&file.path) {
            bail!("远端清单包含重复路径:{}", file.path);
        }
    }
    Ok(())
}

fn temporary_path(target: &Path) -> PathBuf {
    let nonce = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    target.with_extension(format!("codex-sync-{nonce}-{}-tmp", std::process::id()))
}

fn url_encode(s: &str) -> String {
    s.bytes()
        .map(|b| {
            if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
                (b as char).to_string()
            } else {
                format!("%{b:02X}")
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn safe_path_rejects_traversal_and_absolute_paths() {
        let root = Path::new("/tmp/root");
        assert!(safe_path(root, "sessions/chat.jsonl").is_ok());
        assert!(safe_path(root, "../auth.json").is_err());
        assert!(safe_path(root, "/etc/passwd").is_err());
        assert!(safe_path(root, "sessions/./chat.jsonl").is_ok());
    }

    #[test]
    fn manifest_validation_rejects_untrusted_entries() {
        let cfg = Config {
            sync_dir: PathBuf::from("/tmp/codex-sync-test"),
            max_file_size_bytes: 10,
            max_files: 1,
            ..Config::default()
        };
        let file = FileInfo {
            path: "sessions/a.jsonl".into(),
            size: 1,
            modified: 0,
            hash: "0".repeat(64),
        };
        let valid = Manifest {
            node_id: "node".into(),
            node_name: "node".into(),
            files: vec![file.clone()],
        };
        assert!(validate_manifest(&cfg, &valid).is_ok());

        let mut traversal = valid.clone();
        traversal.files[0].path = "../auth.json".into();
        assert!(validate_manifest(&cfg, &traversal).is_err());

        let mut duplicate = valid.clone();
        duplicate.files.push(file);
        assert!(validate_manifest(&cfg, &duplicate).is_err());

        let mut oversized = valid;
        oversized.files[0].size = 11;
        assert!(validate_manifest(&cfg, &oversized).is_err());
    }
}