radicle-lfs-transfer/src/main.rs

230 lines
8.6 KiB
Rust

mod fetch_worker;
mod kubo;
use anyhow::Context as _;
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use fetch_worker::{FetchError, FetchWorker};
use kubo::Kubo;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let kubo = Kubo::new(
std::env::var("KUBO_API_URL").unwrap_or_else(|_| "http://127.0.0.1:5001".to_string()),
);
let stdin = tokio::io::stdin();
let mut lines = BufReader::new(stdin).lines();
let mut stdout = tokio::io::stdout();
// Lazily spawned on the first "download" event, kept alive for the
// rest of the session -- see `fetch_worker`'s doc comment for why.
let mut fetch_worker: Option<FetchWorker> = None;
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
let msg: Value = serde_json::from_str(&line)?;
let event = msg.get("event").and_then(Value::as_str).unwrap_or("");
match event {
"init" => {
let resp = match kubo.healthcheck().await {
Ok(()) => json!({}),
Err(e) => json!({"error": {"code": 1, "message": format!("{e:#}")}}),
};
write_line(&mut stdout, &resp).await?;
}
"upload" => {
let resp = handle_upload(&msg).await;
write_line(&mut stdout, &resp).await?;
}
"download" => {
let resp = handle_download(&msg, &mut fetch_worker).await;
write_line(&mut stdout, &resp).await?;
}
"terminate" => break,
other => {
write_line(
&mut stdout,
&json!({"error": {"code": 2, "message": format!("unknown event: {other}")}}),
)
.await?;
}
}
}
if let Some(worker) = fetch_worker {
worker.shutdown().await;
}
Ok(())
}
async fn handle_upload(msg: &Value) -> Value {
match upload_inner(msg).await {
Ok(oid) => json!({"event": "complete", "oid": oid}),
Err((oid, e)) => json!({
"event": "complete",
"oid": oid,
"error": {"code": 3, "message": format!("{e:#}")},
}),
}
}
/// Delegates entirely to `rad lfs store`, which owns add/pin/note-write
/// (and encrypts first, for private repos) on the Radicle side. It's
/// authoritative and idempotent, so there's no separate "already pinned by
/// the pre-commit hook" fast path to maintain here anymore.
async fn upload_inner(msg: &Value) -> Result<String, (String, anyhow::Error)> {
let oid = field_str(msg, "oid").map_err(|e| (String::new(), e))?;
let size = field_i64(msg, "size").map_err(|e| (oid.clone(), e))?;
let path = field_str(msg, "path").map_err(|e| (oid.clone(), e))?;
let output = Command::new("rad")
.args(["lfs", "store", "--oid", &oid, "--size", &size.to_string(), "--", &path])
.output()
.await
.map_err(|e| {
(
oid.clone(),
anyhow::Error::from(e).context("failed to spawn `rad lfs store`"),
)
})?;
if !output.status.success() {
return Err((
oid,
anyhow::anyhow!("rad lfs store failed: {}", String::from_utf8_lossy(&output.stderr)),
));
}
// `rad lfs store` prints only the resulting CID on success. The custom-transfer
// protocol's "complete" response must echo back the requested oid (not the CID) so
// git-lfs can match it to the in-flight transfer, but we still sanity-check that a
// CID actually came back rather than trusting the exit code alone.
let cid = String::from_utf8_lossy(&output.stdout).trim().to_string();
if cid.is_empty() {
return Err((oid, anyhow::anyhow!("rad lfs store exited successfully but printed no CID")));
}
Ok(oid)
}
async fn handle_download(msg: &Value, worker: &mut Option<FetchWorker>) -> Value {
match download_inner(msg, worker).await {
Ok((oid, path)) => json!({"event": "complete", "oid": oid, "path": path}),
Err((oid, e)) => json!({
"event": "complete",
"oid": oid,
"error": {"code": 3, "message": format!("{e:#}")},
}),
}
}
/// Delegates to the long-lived `rad lfs fetch-batch` worker (see
/// `fetch_worker`), which looks up the note, fetches from IPFS, and
/// decrypts (if needed) on the Radicle side -- kept alive across every
/// download in this session rather than re-spawned per object, so a
/// private repo's passphrase is only prompted for once per session.
async fn download_inner(
msg: &Value,
worker: &mut Option<FetchWorker>,
) -> Result<(String, String), (String, anyhow::Error)> {
let oid = field_str(msg, "oid").map_err(|e| (String::new(), e))?;
let size = field_i64(msg, "size").map_err(|e| (oid.clone(), e))?;
// The path we report back must be on the same filesystem as
// `.git/lfs/objects`, since git-lfs does an atomic `rename()` from
// there into its object store rather than a copy. The system temp
// dir (`/tmp`) can be a different filesystem/mount than the repo --
// on such systems that rename fails outright with "invalid
// cross-device link" rather than falling back to a copy. Using
// `.git/lfs/tmp/` (git-lfs's own existing convention for its own
// temp downloads) guarantees same-filesystem semantics by
// construction.
let tmp_dir = git_lfs_tmp_dir().await.map_err(|e| (oid.clone(), e))?;
let tmp_path = tmp_dir.join(format!("rad-lfs-{oid}"));
let out = tmp_path.to_string_lossy().to_string();
if worker.is_none() {
*worker = Some(
FetchWorker::spawn()
.await
.map_err(|e| (oid.clone(), e.context("failed to start `rad lfs fetch-batch`")))?,
);
}
// One respawn-and-retry if the worker process itself died (broken
// pipe, unexpected exit) -- a per-object failure (wrong passphrase,
// no note, decryption failure) is not retried, since a fresh worker
// wouldn't change that outcome.
match worker.as_mut().expect("just populated above").fetch(&oid, size, &out).await {
Ok(()) => Ok((oid, out)),
Err(FetchError::ObjectFailed(message)) => Err((oid, anyhow::anyhow!(message))),
Err(FetchError::WorkerDied(err)) => {
worker.take();
let mut fresh = FetchWorker::spawn()
.await
.map_err(|e| (oid.clone(), e.context("failed to restart `rad lfs fetch-batch`")))?;
let result = fresh.fetch(&oid, size, &out).await;
*worker = Some(fresh);
match result {
Ok(()) => Ok((oid, out)),
Err(FetchError::ObjectFailed(message)) => Err((oid, anyhow::anyhow!(message))),
Err(FetchError::WorkerDied(retry_err)) => Err((
oid,
retry_err.context(format!("`rad lfs fetch-batch` worker died twice in a row (first: {err:#})")),
)),
}
}
}
}
/// Resolves (and creates, if needed) `.git/lfs/tmp` for the current
/// repository, regardless of whether the working directory is the repo
/// root or a subdirectory of it.
async fn git_lfs_tmp_dir() -> anyhow::Result<std::path::PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.await
.context("failed to spawn `git rev-parse --git-dir`")?;
if !output.status.success() {
anyhow::bail!(
"git rev-parse --git-dir failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let tmp_dir = std::path::PathBuf::from(git_dir).join("lfs").join("tmp");
tokio::fs::create_dir_all(&tmp_dir)
.await
.with_context(|| format!("failed to create `{}`", tmp_dir.display()))?;
Ok(tmp_dir)
}
fn field_str(msg: &Value, key: &str) -> anyhow::Result<String> {
msg.get(key)
.and_then(Value::as_str)
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("missing/invalid field: {key}"))
}
fn field_i64(msg: &Value, key: &str) -> anyhow::Result<i64> {
msg.get(key)
.and_then(Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("missing/invalid field: {key}"))
}
async fn write_line(stdout: &mut tokio::io::Stdout, value: &Value) -> anyhow::Result<()> {
let mut s = serde_json::to_string(value)?;
s.push('\n');
stdout.write_all(s.as_bytes()).await?;
stdout.flush().await?;
Ok(())
}