/// Diff mode implementation. /// /// This module provides functionality for comparing two items and displaying their /// differences using external diff tools. Decompressed content is streamed to diff /// via pipes and /dev/fd file descriptors — no temporary files are created. use crate::config; use crate::services::compression_service::CompressionService; use crate::services::item_service::ItemService; use anyhow::{Context, Result}; use clap::Command; use command_fds::{CommandFdExt, FdMapping}; use log::debug; use nix::fcntl::OFlag; use nix::unistd::pipe2; use std::io::Read; use std::os::unix::io::{AsRawFd, OwnedFd}; fn validate_diff_args(_cmd: &mut Command, ids: &[i64], tags: &[String]) -> anyhow::Result<()> { if !tags.is_empty() { return Err(anyhow::anyhow!( "Tags are not supported with --diff. Please provide exactly two IDs." )); } if ids.len() != 2 { return Err(anyhow::anyhow!( "You must supply exactly two IDs when using --diff." )); } Ok(()) } /// Fetches and validates items from the database for diff operation. fn fetch_and_validate_items( conn: &mut rusqlite::Connection, ids: &[i64], item_service: &ItemService, ) -> Result<( crate::services::types::ItemWithMeta, crate::services::types::ItemWithMeta, )> { let item_a = item_service .get_item(conn, ids[0]) .with_context(|| format!("Unable to find first item (ID: {}) in database", ids[0]))?; let item_b = item_service .get_item(conn, ids[1]) .with_context(|| format!("Unable to find second item (ID: {}) in database", ids[1]))?; debug!("DIFF: Found item A {:?}", item_a.item); debug!("DIFF: Found item B {:?}", item_b.item); Ok((item_a, item_b)) } pub fn mode_diff( cmd: &mut Command, args: &crate::args::Args, conn: &mut rusqlite::Connection, ) -> anyhow::Result<()> { let ids: Vec = args .ids_or_tags .iter() .filter_map(|x| { if let crate::args::NumberOrString::Number(n) = x { Some(*n) } else { None } }) .collect(); let tags: Vec = args .ids_or_tags .iter() .filter_map(|x| { if let crate::args::NumberOrString::Str(s) = x { Some(s.clone()) } else { None } }) .collect(); validate_diff_args(cmd, &ids, &tags)?; let settings = config::Settings::new(args, config::Settings::default_dir()?)?; let item_service = ItemService::new(settings.dir.clone()); let (item_a, item_b) = fetch_and_validate_items(conn, &ids, &item_service)?; run_external_diff(&item_service, &item_a, &item_b) } /// Creates a pipe with CLOEXEC set atomically, returns (read_fd, write_fd). fn create_pipe() -> Result<(OwnedFd, OwnedFd)> { pipe2(OFlag::O_CLOEXEC).context("Failed to create pipe") } /// Streams decompressed item content through a pipe fd. /// /// Returns a JoinHandle for the writer thread. The thread writes decompressed /// data to write_fd and closes it when done (causing EOF for the reader). fn spawn_writer_thread( item_service: &ItemService, item: &crate::services::types::ItemWithMeta, write_fd: OwnedFd, ) -> std::thread::JoinHandle> { let data_path = item_service.get_data_path().clone(); let item_id = item.item.id.expect("item must have ID"); let compression = item.item.compression.clone(); let mut item_path = data_path; item_path.push(item_id.to_string()); std::thread::spawn(move || -> Result<()> { let compression_service = CompressionService::new(); let mut reader = compression_service .stream_item_content(item_path, &compression) .map_err(|e| anyhow::anyhow!("Failed to stream item {item_id}: {e}"))?; // Convert OwnedFd to File — safe, takes ownership, closes on drop let mut writer = std::fs::File::from(write_fd); crate::common::stream_copy(&mut reader, |chunk| { use std::io::Write; writer.write_all(chunk) }) .map_err(|e| anyhow::anyhow!("Error reading item {item_id}: {e}"))?; // writer dropped here, closing write_fd → diff sees EOF Ok(()) }) } /// Runs external diff command, streaming decompressed content via /dev/fd pipes. /// /// Creates two pipes, spawns writer threads to decompress each item into its pipe, /// and runs `diff -u /dev/fd/N /dev/fd/M` where N and M are the pipe read fds. /// The `command-fds` crate handles CLOEXEC clearing safely — no unsafe needed. fn run_external_diff( item_service: &ItemService, item_a: &crate::services::types::ItemWithMeta, item_b: &crate::services::types::ItemWithMeta, ) -> Result<()> { if which::which_global("diff").is_err() { return Err(anyhow::anyhow!( "diff command not found. Please install diffutils." )); } let (read_fd_a, write_fd_a) = create_pipe()?; let (read_fd_b, write_fd_b) = create_pipe()?; // Spawn writer threads — they take ownership of write fds and close them on exit let writer_a = spawn_writer_thread(item_service, item_a, write_fd_a); let writer_b = spawn_writer_thread(item_service, item_b, write_fd_b); // Get fd numbers for /dev/fd paths (borrows, does not consume) let raw_read_a = read_fd_a.as_raw_fd(); let raw_read_b = read_fd_b.as_raw_fd(); debug!("DIFF: pipe fds: a(r={raw_read_a}) b(r={raw_read_b})"); // Spawn diff with /dev/fd/N paths. command-fds handles CLOEXEC clearing // and fd inheritance safely — the fds are released from OwnedFd to the // child process. If spawn fails, the OwnedFd values in FdMapping are // dropped and the fds are properly closed. let mut command = std::process::Command::new("diff"); command .arg("-u") .arg(format!("/dev/fd/{raw_read_a}")) .arg(format!("/dev/fd/{raw_read_b}")) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .stdin(std::process::Stdio::null()) .fd_mappings(vec![ FdMapping { parent_fd: read_fd_a, child_fd: raw_read_a, }, FdMapping { parent_fd: read_fd_b, child_fd: raw_read_b, }, ]) .map_err(|e| anyhow::anyhow!("FD mapping collision: {e}"))?; let mut child = command.spawn().context("Failed to spawn diff command")?; let status = child.wait().context("Failed to wait for diff command")?; // Join writer threads and propagate errors writer_a .join() .map_err(|e| anyhow::anyhow!("Writer A panicked: {e:?}"))??; writer_b .join() .map_err(|e| anyhow::anyhow!("Writer B panicked: {e:?}"))??; // diff returns 0 if identical, 1 if different, 2 on error if status.code() == Some(2) { Err(anyhow::anyhow!("diff command failed with an error")) } else { Ok(()) } }