second-brain-sync 0.4.0

Bidirectional sync for second-brain: SSH transport, JSONL change log, conflict resolution
Documentation
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use second_brain_core::machine::MachineIdentity;

pub fn known_hosts_path_in(config_dir: &Path) -> PathBuf {
    config_dir.join("known_hosts")
}

pub fn known_hosts_path() -> Result<PathBuf> {
    Ok(known_hosts_path_in(&MachineIdentity::config_dir()?))
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyscanLine {
    pub host: String,
    pub key_type: String,
    pub public_key: String,
}

pub fn parse_keyscan_output(raw: &str) -> Vec<KeyscanLine> {
    raw.lines()
        .filter_map(|line| {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                return None;
            }
            let mut parts = line.split_whitespace();
            let host = parts.next()?;
            let key_type = parts.next()?;
            let public_key = parts.next()?;
            Some(KeyscanLine {
                host: host.to_string(),
                key_type: key_type.to_string(),
                public_key: public_key.to_string(),
            })
        })
        .collect()
}

pub fn compose_known_host_entry(line: &KeyscanLine) -> String {
    format!("{} {} {}\n", line.host, line.key_type, line.public_key)
}

pub fn append_known_host(path: &Path, lines: &[KeyscanLine]) -> Result<()> {
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent).context("creating known_hosts parent dir")?;
    }

    let existing = match fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(e) => return Err(e).context("reading known_hosts"),
    };
    let already: HashSet<&str> = existing
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty())
        .collect();

    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .mode(0o600)
        .open(path)
        .context("opening known_hosts for append")?;

    for line in lines {
        let entry = compose_known_host_entry(line);
        if already.contains(entry.trim_end()) {
            continue;
        }
        file.write_all(entry.as_bytes())
            .context("writing known_hosts entry")?;
    }

    // because OpenOptions::mode only applies on file creation, tighten unconditionally
    // so an existing permissive file is corrected on first append.
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
        .context("setting known_hosts mode 0600")?;

    Ok(())
}

pub fn untrusted_host_hint(host: &str) -> String {
    format!(
        "if ssh reported a host-key error, the host is not in ~/.second-brain/known_hosts yet — \
         run `sb sync trust {host}` to verify and pin its fingerprint."
    )
}

pub fn ssh_args(known_hosts: &Path) -> Vec<OsString> {
    let mut user_kh = OsString::from("UserKnownHostsFile=");
    user_kh.push(known_hosts);

    vec![
        OsString::from("-o"),
        OsString::from("StrictHostKeyChecking=yes"),
        OsString::from("-o"),
        user_kh,
        OsString::from("-o"),
        OsString::from("GlobalKnownHostsFile=/dev/null"),
        OsString::from("-o"),
        OsString::from("ServerAliveInterval=30"),
    ]
}