use std::{
io::BufRead,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::{Arc, Mutex, mpsc, mpsc::Receiver},
};
use anyhow::{Context, Result, bail};
pub const PERSONAL: [&str; 3] = ["progress.json", "recent.json", "alix.local.toml"];
fn stays_home(name: &str) -> bool {
PERSONAL.contains(&name)
|| name.starts_with('.')
|| name.ends_with(".bak")
|| name.ends_with("-bak")
}
pub fn stage_dir(dir: &Path, stage: &Path) -> Result<usize> {
std::fs::create_dir_all(stage).with_context(|| format!("cannot create {}", stage.display()))?;
let mut staged = 0;
for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if stays_home(&name) {
continue;
}
let from = entry.path();
let to = stage.join(&name);
if from.is_dir() {
staged += stage_dir(&from, &to)?;
} else {
std::fs::copy(&from, &to).with_context(|| format!("cannot copy {}", from.display()))?;
staged += 1;
}
}
Ok(staged)
}
pub fn sanitize_received(dir: &Path) -> Result<Vec<String>> {
let mut removed = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
if PERSONAL.contains(&name.as_str()) {
if path.is_file() {
std::fs::remove_file(&path)?;
removed.push(name);
}
} else if path.is_dir() {
for inner in sanitize_received(&path)? {
removed.push(format!("{name}/{inner}"));
}
}
}
Ok(removed)
}
pub fn move_into(from: &Path, to: &Path) -> Result<()> {
if std::fs::rename(from, to).is_ok() {
return Ok(());
}
if from.is_dir() {
copy_tree(from, to)?;
std::fs::remove_dir_all(from).ok();
} else {
std::fs::copy(from, to).with_context(|| format!("cannot copy to {}", to.display()))?;
std::fs::remove_file(from).ok();
}
Ok(())
}
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let dest = to.join(entry.file_name());
if entry.path().is_dir() {
copy_tree(&entry.path(), &dest)?;
} else {
std::fs::copy(entry.path(), &dest)?;
}
}
Ok(())
}
pub fn zip_to(path: &Path, out: &Path) -> Result<usize> {
use std::io::Write;
let file =
std::fs::File::create(out).with_context(|| format!("cannot create {}", out.display()))?;
let mut zip = zip::ZipWriter::new(file);
let options: zip::write::SimpleFileOptions = Default::default();
let mut entries = 0;
if path.is_file() {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "deck.md".to_string());
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(path)?)?;
entries = 1;
} else {
let root = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "decks".to_string());
zip_walk(&mut zip, path, &root, options, &mut entries)?;
}
zip.finish()?;
Ok(entries)
}
fn zip_walk(
zip: &mut zip::ZipWriter<std::fs::File>,
dir: &Path,
prefix: &str,
options: zip::write::SimpleFileOptions,
entries: &mut usize,
) -> Result<()> {
use std::io::Write;
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
if entry.path().is_dir() {
zip_walk(zip, &entry.path(), &name, options, entries)?;
} else {
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(entry.path())?)?;
*entries += 1;
}
}
Ok(())
}
pub fn unzip_to(zip_path: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(zip_path)
.with_context(|| format!("cannot open {}", zip_path.display()))?;
let mut archive = zip::ZipArchive::new(file)
.with_context(|| format!("{} is not a readable zip archive", zip_path.display()))?;
archive
.extract(dest)
.with_context(|| format!("cannot extract {}", zip_path.display()))?;
Ok(())
}
pub fn wormhole(args: &[&str], cwd: Option<&Path>) -> Result<()> {
wormhole_with("wormhole", args, cwd)
}
fn wormhole_with(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
let mut command = Command::new(cmd);
command.args(args);
if let Some(dir) = cwd {
command.current_dir(dir);
}
let status = command.status().with_context(|| {
format!(
"cannot run `{cmd}` — is magic-wormhole installed? \
(e.g. `pipx install magic-wormhole`, or your package manager)"
)
})?;
if !status.success() {
bail!("`{cmd} {}` failed", args.join(" "));
}
Ok(())
}
#[derive(Debug)]
pub enum ShareEvent {
Code(String),
Done,
Error(String),
}
pub struct ShareJob {
pub events: Receiver<ShareEvent>,
child: Arc<Mutex<Child>>,
}
impl ShareJob {
pub fn cancel(&self) {
if let Ok(mut c) = self.child.lock() {
c.kill().ok();
}
}
}
impl Drop for ShareJob {
fn drop(&mut self) {
self.cancel();
}
}
pub fn send_spawn(path: &Path) -> Result<ShareJob> {
spawn_job("wormhole", &["send", &path.to_string_lossy()], None)
}
pub fn receive_spawn(code: &str, dest: &Path) -> Result<ShareJob> {
spawn_job("wormhole", &["receive", "--accept-file", code], Some(dest))
}
fn spawn_job(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<ShareJob> {
let mut command = Command::new(cmd);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
command.current_dir(dir);
}
let mut child = command.spawn().with_context(|| {
format!(
"cannot run `{cmd}` — is magic-wormhole installed? \
(e.g. `pipx install magic-wormhole`, or your package manager)"
)
})?;
let (tx, rx) = mpsc::channel();
let mut readers = Vec::new();
for pipe in [
child
.stdout
.take()
.map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
child
.stderr
.take()
.map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
]
.into_iter()
.flatten()
{
let tx = tx.clone();
readers.push(std::thread::spawn(move || {
for line in std::io::BufReader::new(pipe).lines().map_while(|l| l.ok()) {
if let Some(code) = line.trim().strip_prefix("Wormhole code is:") {
let _ = tx.send(ShareEvent::Code(code.trim().to_string()));
}
}
}));
}
let child = Arc::new(Mutex::new(child));
let waiter = Arc::clone(&child);
std::thread::spawn(move || {
loop {
let status = waiter
.lock()
.ok()
.and_then(|mut c| c.try_wait().ok().flatten());
if let Some(status) = status {
for reader in readers.drain(..) {
let _ = reader.join();
}
let _ = tx.send(if status.success() {
ShareEvent::Done
} else {
ShareEvent::Error("the wormhole transfer failed or was cancelled".to_string())
});
return;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
});
Ok(ShareJob { events: rx, child })
}
pub fn land_received(tmp: &Path, dest_dir: &Path) -> Result<(String, Vec<String>)> {
let mut entries: Vec<PathBuf> = std::fs::read_dir(tmp)?
.flatten()
.map(|e| e.path())
.collect();
let Some(got) = entries.pop().filter(|_| entries.is_empty()) else {
bail!("expected exactly one received file or folder");
};
let name = got
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("received")
.to_string();
let stripped = if got.is_dir() {
sanitize_received(&got)?
} else {
Vec::new()
};
let dest = dest_dir.join(&name);
if dest.exists() {
bail!("{} already exists — move it aside first", dest.display());
}
move_into(&got, &dest)?;
Ok((name, stripped))
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "x").unwrap();
}
#[test]
fn staging_excludes_personal_state_and_keeps_content() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("ws");
std::fs::create_dir_all(src.join("assets")).unwrap();
touch(&src, "a.txt");
touch(&src, "alix.toml");
touch(&src, "augment.json");
touch(&src, "progress.json");
touch(&src, "recent.json");
touch(&src, "alix.local.toml");
touch(&src, "progress.json.predepth-bak");
touch(&src.join("assets"), "icon.svg");
let stage = dir.path().join("stage");
let n = stage_dir(&src, &stage).unwrap();
assert_eq!(4, n, "a.txt, alix.toml, augment.json, assets/icon.svg");
assert!(stage.join("a.txt").exists());
assert!(stage.join("alix.toml").exists());
assert!(stage.join("augment.json").exists());
assert!(stage.join("assets/icon.svg").exists());
assert!(!stage.join("progress.json").exists());
assert!(!stage.join("recent.json").exists());
assert!(!stage.join("alix.local.toml").exists());
assert!(!stage.join("progress.json.predepth-bak").exists());
}
#[test]
fn sanitize_strips_leaked_personal_files_at_any_depth() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("got");
std::fs::create_dir_all(root.join("nested")).unwrap();
touch(&root, "a.txt");
touch(&root, "progress.json");
touch(&root.join("nested"), "alix.local.toml");
let removed = sanitize_received(&root).unwrap();
assert!(root.join("a.txt").exists());
assert!(!root.join("progress.json").exists());
assert!(!root.join("nested/alix.local.toml").exists());
assert_eq!(2, removed.len(), "{removed:?}");
}
#[test]
fn zipping_a_staged_folder_writes_every_entry_under_its_name() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("ws");
std::fs::create_dir_all(src.join("assets")).unwrap();
touch(&src, "a.txt");
touch(&src.join("assets"), "icon.svg");
let out = dir.path().join("ws.zip");
let n = zip_to(&src, &out).unwrap();
assert_eq!(2, n);
let mut archive = zip::ZipArchive::new(std::fs::File::open(&out).unwrap()).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(names.contains(&"ws/a.txt".to_string()), "{names:?}");
assert!(
names.contains(&"ws/assets/icon.svg".to_string()),
"{names:?}"
);
}
#[test]
fn a_zip_round_trip_restores_the_staged_tree() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("ws");
std::fs::create_dir_all(src.join("assets")).unwrap();
touch(&src, "a.txt");
touch(&src.join("assets"), "icon.svg");
let archive = dir.path().join("ws.zip");
zip_to(&src, &archive).unwrap();
let out = dir.path().join("landed");
unzip_to(&archive, &out).unwrap();
assert!(out.join("ws/a.txt").exists());
assert!(out.join("ws/assets/icon.svg").exists());
}
#[test]
fn a_missing_wormhole_binary_errors_with_the_install_hint() {
let err = wormhole_with("definitely-not-wormhole-xyz", &["send"], None).unwrap_err();
assert!(
format!("{err:#}").contains("magic-wormhole installed"),
"{err:#}"
);
}
#[test]
fn a_send_job_reports_the_code_then_done() {
let _lock = crate::testutil::exec_lock();
let dir = tempfile::tempdir().unwrap();
let fake =
crate::testutil::fake_cli(dir.path(), "echo 'Wormhole code is: 7-alpha-bravo'\nexit 0");
let job = spawn_job(&fake.to_string_lossy(), &["send", "x"], None).unwrap();
let mut got = Vec::new();
while let Ok(ev) = job.events.recv_timeout(std::time::Duration::from_secs(10)) {
got.push(ev);
}
assert!(
matches!(got.first(), Some(ShareEvent::Code(c)) if c == "7-alpha-bravo"),
"{got:?}"
);
assert!(matches!(got.last(), Some(ShareEvent::Done)), "{got:?}");
}
#[test]
fn a_failing_send_job_reports_an_error() {
let _lock = crate::testutil::exec_lock();
let dir = tempfile::tempdir().unwrap();
let fake = crate::testutil::fake_cli(dir.path(), "exit 1");
let job = spawn_job(&fake.to_string_lossy(), &["send", "x"], None).unwrap();
let last = std::iter::from_fn(|| {
job.events
.recv_timeout(std::time::Duration::from_secs(10))
.ok()
})
.last();
assert!(matches!(last, Some(ShareEvent::Error(_))), "{last:?}");
}
#[test]
fn cancelling_a_running_job_reports_an_error_event_promptly() {
let _lock = crate::testutil::exec_lock();
let dir = tempfile::tempdir().unwrap();
let fake = crate::testutil::fake_cli(dir.path(), "sleep 30");
let job = spawn_job(&fake.to_string_lossy(), &["send", "x"], None).unwrap();
job.cancel();
let ev = job
.events
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
assert!(matches!(ev, ShareEvent::Error(_)), "{ev:?}");
}
#[test]
fn landing_a_received_folder_sanitizes_and_moves_it() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("scratch");
std::fs::create_dir_all(tmp.join("ws")).unwrap();
std::fs::write(tmp.join("ws/a.txt"), "x").unwrap();
std::fs::write(tmp.join("ws/progress.json"), "x").unwrap();
let dest = dir.path().join("decks");
std::fs::create_dir_all(&dest).unwrap();
let (landed, stripped) = land_received(&tmp, &dest).unwrap();
assert_eq!("ws", landed);
assert_eq!(vec!["progress.json".to_string()], stripped);
assert!(dest.join("ws/a.txt").exists());
assert!(!dest.join("ws/progress.json").exists());
}
#[test]
fn landing_onto_an_existing_name_errors_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("scratch");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("a.txt"), "new").unwrap();
let dest = dir.path().join("decks");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("a.txt"), "old").unwrap();
assert!(land_received(&tmp, &dest).is_err());
assert_eq!("old", std::fs::read_to_string(dest.join("a.txt")).unwrap());
}
}