- Add SaveMetaFn callback pattern: meta plugins receive a closure instead of
&Connection, enabling the same plugin code to work in local, client, and
server contexts (collect-to-Vec, collect-to-HashMap, or direct DB write)
- Client save now runs meta plugins locally during streaming (smart client
sets meta=false, server skips its own plugins)
- Add POST /api/item/{id}/update endpoint for re-running plugins on stored
content without downloading compressed data
- Add client update mode (--update with --meta-plugin flags)
- Extract shared utilities: stream_copy, print_serialized, build_path_table,
ensure_default_tag to reduce duplication across modes
- Add upsert_tag for idempotent tag addition (INSERT OR IGNORE)
- Add warn logging on save_meta lock failure in BaseMetaPlugin and MetaService
201 lines
7.0 KiB
Rust
201 lines
7.0 KiB
Rust
/// 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<i64> = args
|
|
.ids_or_tags
|
|
.iter()
|
|
.filter_map(|x| {
|
|
if let crate::args::NumberOrString::Number(n) = x {
|
|
Some(*n)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let tags: Vec<String> = 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<Result<()>> {
|
|
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(())
|
|
}
|
|
}
|