1use std::path::{Path, PathBuf};
11use std::str::FromStr;
12use std::sync::Arc;
13
14use thiserror::Error;
15use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite};
16use tracing::{debug, error, warn};
17
18use crate::lfs::agent::{self, Agent, AgentError, ERR_CODE_GENERIC, ERR_CODE_INIT};
19use crate::lfs::oid::LfsOid;
20use crate::lfs::protocol::{CompleteEvent, ErrorPayload, Event, InitEvent, InitResponse};
21use crate::object_store::ObjectStore;
22use crate::protocol::backend;
23use crate::url;
24
25#[derive(Debug, Error)]
31pub enum RunError {
32 #[error("LFS protocol I/O error: {0}")]
34 Io(#[from] std::io::Error),
35 #[error(transparent)]
37 Agent(#[from] AgentError),
38 #[error("malformed LFS event: {0}")]
42 MalformedEvent(#[from] serde_json::Error),
43 #[error("expected init as the first event, got {0}")]
47 InitNotFirst(String),
48 #[error("stdin closed before init")]
50 StdinClosed,
51}
52
53impl RunError {
54 #[must_use]
60 pub fn is_broken_pipe(&self) -> bool {
61 let io_err = match self {
62 Self::Io(e) | Self::Agent(AgentError::Io(e)) => Some(e),
63 _ => None,
64 };
65 io_err.is_some_and(|e| {
66 matches!(
67 e.kind(),
68 std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::WriteZero,
69 )
70 })
71 }
72}
73
74#[derive(Debug, Error)]
79enum InitError {
80 #[error("init.remote is empty")]
83 EmptyRemote,
84 #[error("cannot resolve remote \"{remote}\": {source}")]
87 Resolve {
88 remote: String,
90 #[source]
92 source: Box<dyn std::error::Error + Send + Sync>,
93 },
94}
95
96#[async_trait::async_trait]
100pub trait RemoteResolver: Send + Sync {
101 async fn resolve(
103 &self,
104 remote_name: &str,
105 ) -> Result<(Arc<dyn ObjectStore>, Option<String>), Box<dyn std::error::Error + Send + Sync>>;
106}
107
108pub struct GitRemoteResolver {
112 pub repo_dir: PathBuf,
115}
116
117#[async_trait::async_trait]
118impl RemoteResolver for GitRemoteResolver {
119 async fn resolve(
120 &self,
121 remote_name: &str,
122 ) -> Result<(Arc<dyn ObjectStore>, Option<String>), Box<dyn std::error::Error + Send + Sync>>
123 {
124 let repo = gix::discover(&self.repo_dir)?;
128 let raw = crate::git::remote_url(&repo, remote_name)?;
129 let parsed = url::parse(&raw)?;
130 let prefix = parsed.prefix().map(str::to_owned);
131 let (store, _engine) = backend::build(&parsed).await?;
135 Ok((store, prefix))
136 }
137}
138
139pub async fn run<R, W, Res>(
152 reader: R,
153 mut writer: W,
154 resolver: &Res,
155 tmp_dir: &Path,
156) -> Result<(), RunError>
157where
158 R: AsyncBufRead + Unpin,
159 W: AsyncWrite + Unpin,
160 Res: RemoteResolver + ?Sized,
161{
162 let mut lines = reader.lines();
163
164 let Some(first) = lines.next_line().await? else {
165 return Err(RunError::StdinClosed);
166 };
167 let event = parse_event(&first)?;
168 let init = match event {
169 Event::Init(init) => init,
170 Event::Terminate => {
171 debug!("received terminate before init; exiting");
174 return Ok(());
175 }
176 other => {
177 return Err(RunError::InitNotFirst(format!("{other:?}")));
178 }
179 };
180
181 let agent = match init_agent(&init, resolver, tmp_dir.to_owned()).await {
182 Ok(a) => {
183 write_init_ack(&mut writer, None).await?;
184 a
185 }
186 Err(err) => {
187 error!(error = %err, "init failed");
188 write_init_ack(&mut writer, Some(&err.to_string())).await?;
189 return Ok(());
190 }
191 };
192
193 while let Some(line) = lines.next_line().await? {
194 debug!(line = %line, "lfs event");
195 let event = parse_event(&line)?;
196 match event {
197 Event::Init(_) => {
198 warn!("received second init; ignoring");
199 }
200 Event::Upload(u) => {
201 if let Some(oid) = validate_oid(&u.oid, &mut writer, "upload").await? {
202 agent
203 .upload(&oid, u.size, Path::new(&u.path), &mut writer)
204 .await?;
205 }
206 }
207 Event::Download(d) => {
208 if let Some(oid) = validate_oid(&d.oid, &mut writer, "download").await? {
209 agent.download(&oid, d.size, &mut writer).await?;
210 }
211 }
212 Event::Terminate => {
213 debug!("received terminate; exiting");
214 break;
215 }
216 }
217 }
218 Ok(())
219}
220
221fn parse_event(line: &str) -> Result<Event, RunError> {
222 Ok(serde_json::from_str(line)?)
226}
227
228async fn init_agent<Res>(
229 init: &InitEvent,
230 resolver: &Res,
231 tmp_dir: PathBuf,
232) -> Result<Agent, InitError>
233where
234 Res: RemoteResolver + ?Sized,
235{
236 if init.remote.is_empty() {
237 return Err(InitError::EmptyRemote);
238 }
239 let (store, prefix) =
240 resolver
241 .resolve(&init.remote)
242 .await
243 .map_err(|source| InitError::Resolve {
244 remote: init.remote.clone(),
245 source,
246 })?;
247 Ok(Agent::new(store, prefix, tmp_dir))
248}
249
250async fn validate_oid<W: AsyncWrite + Unpin>(
263 oid_raw: &str,
264 writer: &mut W,
265 op: &'static str,
266) -> Result<Option<LfsOid>, RunError> {
267 match LfsOid::from_str(oid_raw) {
268 Ok(oid) => Ok(Some(oid)),
269 Err(err) => {
270 warn!(oid = %oid_raw, error = %err, op, "rejecting malformed oid");
271 let message = format!("invalid oid `{oid_raw}`: {err}");
272 let evt = CompleteEvent {
273 event: "complete",
274 oid: oid_raw,
275 path: None,
276 error: Some(ErrorPayload {
277 code: ERR_CODE_GENERIC,
278 message: &message,
279 }),
280 };
281 agent::write_event(writer, &evt).await?;
282 Ok(None)
283 }
284 }
285}
286
287async fn write_init_ack<W: AsyncWrite + Unpin>(
288 writer: &mut W,
289 error_msg: Option<&str>,
290) -> Result<(), RunError> {
291 let resp = InitResponse {
292 error: error_msg.map(|m| ErrorPayload {
293 code: ERR_CODE_INIT,
294 message: m,
295 }),
296 };
297 Ok(agent::write_event(writer, &resp).await?)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::object_store::mock::MockStore;
304 use bytes::Bytes;
305 use tempfile::TempDir;
306
307 struct StubResolver {
308 store: MockStore,
309 prefix: Option<String>,
310 }
311
312 #[async_trait::async_trait]
313 impl RemoteResolver for StubResolver {
314 async fn resolve(
315 &self,
316 _remote_name: &str,
317 ) -> Result<(Arc<dyn ObjectStore>, Option<String>), Box<dyn std::error::Error + Send + Sync>>
318 {
319 Ok((Arc::new(self.store.clone()), self.prefix.clone()))
320 }
321 }
322
323 fn good_oid() -> String {
324 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".to_owned()
325 }
326
327 async fn drive(
328 events: &[String],
329 resolver: &dyn RemoteResolver,
330 tmp_dir: &Path,
331 ) -> (Vec<String>, Result<(), RunError>) {
332 let mut input = events.join("\n");
333 if !events.is_empty() {
334 input.push('\n');
335 }
336 let reader = tokio::io::BufReader::new(std::io::Cursor::new(input.into_bytes()));
337 let mut output: Vec<u8> = Vec::new();
338 let res = run(reader, &mut output, resolver, tmp_dir).await;
339 let lines = String::from_utf8(output)
340 .unwrap()
341 .lines()
342 .map(str::to_owned)
343 .collect();
344 (lines, res)
345 }
346
347 #[tokio::test]
348 async fn full_round_trip_init_upload_download_terminate() {
349 let store = MockStore::new();
350 let oid = good_oid();
351 let body = b"some body";
352 let oid2 = good_oid();
354 store.insert(format!("repo/lfs/{oid2}"), Bytes::from_static(body));
355
356 let resolver = StubResolver {
357 store: store.clone(),
358 prefix: Some("repo".to_owned()),
359 };
360
361 let tmp = TempDir::new().unwrap();
362 let src = tmp.path().join("src");
363 tokio::fs::write(&src, body).await.unwrap();
364
365 let events = vec![
366 r#"{"event":"init","operation":"upload","remote":"origin"}"#.to_owned(),
367 format!(
368 r#"{{"event":"upload","oid":"{oid}","size":{size},"path":"{path}"}}"#,
369 size = body.len(),
370 path = src.to_str().unwrap(),
371 ),
372 format!(
373 r#"{{"event":"download","oid":"{oid2}","size":{size}}}"#,
374 size = body.len(),
375 ),
376 r#"{"event":"terminate"}"#.to_owned(),
377 ];
378 let (lines, res) = drive(&events, &resolver, tmp.path()).await;
379 res.expect("run should exit cleanly");
380
381 assert_eq!(lines[0], "{}", "init ack should be empty object");
383 assert!(lines.iter().any(|l| l.contains("\"event\":\"progress\"")));
384 let completes: Vec<_> = lines
385 .iter()
386 .filter(|l| l.contains("\"event\":\"complete\""))
387 .collect();
388 assert_eq!(completes.len(), 2, "expected two completes: {lines:?}");
389 assert!(store.contains(&format!("repo/lfs/{oid}")));
390 }
391
392 #[tokio::test]
393 async fn init_failure_emits_error_object_and_exits_cleanly() {
394 struct FailingResolver;
395 #[async_trait::async_trait]
396 impl RemoteResolver for FailingResolver {
397 async fn resolve(
398 &self,
399 _remote_name: &str,
400 ) -> Result<
401 (Arc<dyn ObjectStore>, Option<String>),
402 Box<dyn std::error::Error + Send + Sync>,
403 > {
404 Err("no such remote".into())
405 }
406 }
407 let tmp = TempDir::new().unwrap();
408 let events = vec![r#"{"event":"init","remote":"origin"}"#.to_owned()];
409 let (lines, res) = drive(&events, &FailingResolver, tmp.path()).await;
410 res.expect("init failure is non-fatal");
411 assert_eq!(lines.len(), 1);
412 assert!(lines[0].contains("\"error\""));
413 assert!(lines[0].contains(&format!("\"code\":{ERR_CODE_INIT}")));
414 }
415
416 #[tokio::test]
417 async fn first_non_init_event_is_fatal() {
418 let store = MockStore::new();
419 let resolver = StubResolver {
420 store,
421 prefix: Some("repo".into()),
422 };
423 let tmp = TempDir::new().unwrap();
424 let events = vec![r#"{"event":"upload","oid":"abc","size":1,"path":"/tmp/x"}"#.to_owned()];
425 let (_, res) = drive(&events, &resolver, tmp.path()).await;
426 let err = res.expect_err("non-init first event must error");
427 assert!(matches!(err, RunError::InitNotFirst(_)));
428 }
429
430 #[test]
431 fn init_not_first_display_does_not_double_quote_payload() {
432 let err = RunError::InitNotFirst("Upload(UploadEvent { oid: \"abc\" })".to_owned());
437 let rendered = err.to_string();
438 assert!(
439 rendered.starts_with("expected init as the first event, got Upload(UploadEvent {"),
440 "InitNotFirst should not wrap the payload in extra quotes: {rendered}"
441 );
442 }
443
444 #[tokio::test]
445 async fn empty_remote_in_init_emits_error_object_and_exits_cleanly() {
446 struct UnreachableResolver;
450 #[async_trait::async_trait]
451 impl RemoteResolver for UnreachableResolver {
452 async fn resolve(
453 &self,
454 _remote_name: &str,
455 ) -> Result<
456 (Arc<dyn ObjectStore>, Option<String>),
457 Box<dyn std::error::Error + Send + Sync>,
458 > {
459 panic!("resolver should not be called when init.remote is empty");
460 }
461 }
462 let tmp = TempDir::new().unwrap();
463 let events = vec![r#"{"event":"init","remote":""}"#.to_owned()];
464 let (lines, res) = drive(&events, &UnreachableResolver, tmp.path()).await;
465 res.expect("empty-remote init failure is non-fatal");
466 assert_eq!(lines.len(), 1);
467 assert!(lines[0].contains("\"error\""));
468 assert!(lines[0].contains(&format!("\"code\":{ERR_CODE_INIT}")));
469 assert!(
470 lines[0].contains("init.remote is empty"),
471 "ack should include the InitError::EmptyRemote message: {}",
472 lines[0]
473 );
474 }
475
476 #[tokio::test]
477 async fn broken_pipe_during_init_ack_is_clean_exit() {
478 use tokio::io::duplex;
483
484 let (writer, reader) = duplex(64);
487 drop(reader); let store = MockStore::new();
490 let resolver = StubResolver {
491 store,
492 prefix: None,
493 };
494 let tmp = TempDir::new().unwrap();
495 let input = r#"{"event":"init","remote":"origin"}"#;
496 let buffered = tokio::io::BufReader::new(std::io::Cursor::new(input.as_bytes().to_vec()));
497
498 let res = run(buffered, writer, &resolver, tmp.path()).await;
499 let err = res.expect_err("write to closed duplex must surface as Err");
500 assert!(
501 err.is_broken_pipe(),
502 "init-ack BrokenPipe must be classified as broken-pipe, got: {err:?}"
503 );
504 }
505
506 #[tokio::test]
507 async fn malformed_json_is_fatal() {
508 let store = MockStore::new();
509 let resolver = StubResolver {
510 store,
511 prefix: None,
512 };
513 let tmp = TempDir::new().unwrap();
514 let events = vec!["not json".to_owned()];
515 let (_, res) = drive(&events, &resolver, tmp.path()).await;
516 let err = res.expect_err("garbage line must error");
517 assert!(matches!(err, RunError::MalformedEvent(_)));
518 }
519
520 #[tokio::test]
521 async fn empty_stdin_returns_stdin_closed() {
522 let store = MockStore::new();
523 let resolver = StubResolver {
524 store,
525 prefix: None,
526 };
527 let tmp = TempDir::new().unwrap();
528 let (_, res) = drive(&[], &resolver, tmp.path()).await;
529 assert!(matches!(res, Err(RunError::StdinClosed)));
530 }
531
532 #[tokio::test]
539 async fn upload_with_invalid_oid_echoes_raw_oid_in_complete() {
540 let store = MockStore::new();
541 let resolver = StubResolver {
542 store,
543 prefix: Some("repo".to_owned()),
544 };
545 let tmp = TempDir::new().unwrap();
546 let bad_oid = "not-a-real-oid";
547 let src = tmp.path().join("body");
548 tokio::fs::write(&src, b"x").await.unwrap();
549 let events = vec![
550 r#"{"event":"init","operation":"upload","remote":"origin"}"#.to_owned(),
551 format!(
552 r#"{{"event":"upload","oid":"{bad_oid}","size":1,"path":"{path}"}}"#,
553 path = src.to_str().unwrap(),
554 ),
555 r#"{"event":"terminate"}"#.to_owned(),
556 ];
557 let (lines, res) = drive(&events, &resolver, tmp.path()).await;
558 res.expect("run completes despite bad oid");
559 assert!(
561 lines.len() >= 2,
562 "expected init ack and complete: {lines:?}"
563 );
564 let complete_line = lines
565 .iter()
566 .find(|l| l.contains("\"event\":\"complete\""))
567 .expect("complete event present");
568 assert_eq!(
573 complete_line.as_str(),
574 r#"{"event":"complete","oid":"not-a-real-oid","error":{"code":2,"message":"invalid oid `not-a-real-oid`: LFS oid must be 64 chars, got 14"}}"#,
575 );
576 }
577
578 #[tokio::test]
583 async fn download_with_invalid_oid_echoes_raw_oid_in_complete() {
584 let store = MockStore::new();
585 let resolver = StubResolver {
586 store,
587 prefix: Some("repo".to_owned()),
588 };
589 let tmp = TempDir::new().unwrap();
590 let bad_oid = "DEADBEEF";
591 let events = vec![
592 r#"{"event":"init","operation":"download","remote":"origin"}"#.to_owned(),
593 format!(r#"{{"event":"download","oid":"{bad_oid}","size":1}}"#),
594 r#"{"event":"terminate"}"#.to_owned(),
595 ];
596 let (lines, res) = drive(&events, &resolver, tmp.path()).await;
597 res.expect("run completes despite bad oid");
598 let complete_line = lines
599 .iter()
600 .find(|l| l.contains("\"event\":\"complete\""))
601 .expect("complete event present");
602 assert_eq!(
606 complete_line.as_str(),
607 r#"{"event":"complete","oid":"DEADBEEF","error":{"code":2,"message":"invalid oid `DEADBEEF`: LFS oid must be 64 chars, got 8"}}"#,
608 );
609 }
610
611 #[tokio::test]
617 async fn download_with_uppercase_oid_echoes_exact_bytes() {
618 let store = MockStore::new();
619 let resolver = StubResolver {
620 store,
621 prefix: Some("repo".to_owned()),
622 };
623 let tmp = TempDir::new().unwrap();
624 let bad_oid = "FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210";
626 let events = vec![
627 r#"{"event":"init","operation":"download","remote":"origin"}"#.to_owned(),
628 format!(r#"{{"event":"download","oid":"{bad_oid}","size":1}}"#),
629 r#"{"event":"terminate"}"#.to_owned(),
630 ];
631 let (lines, res) = drive(&events, &resolver, tmp.path()).await;
632 res.expect("run completes despite bad oid");
633 let complete_line = lines
634 .iter()
635 .find(|l| l.contains("\"event\":\"complete\""))
636 .expect("complete event present");
637 assert_eq!(
638 complete_line.as_str(),
639 r#"{"event":"complete","oid":"FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210","error":{"code":2,"message":"invalid oid `FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210`: LFS oid must be lowercase hex (0-9, a-f)"}}"#,
640 );
641 }
642
643 #[tokio::test]
648 async fn upload_with_valid_oid_reaches_agent() {
649 let store = MockStore::new();
650 let resolver = StubResolver {
651 store: store.clone(),
652 prefix: Some("repo".to_owned()),
653 };
654 let tmp = TempDir::new().unwrap();
655 let oid = good_oid();
656 let src = tmp.path().join("body");
657 let body = b"payload";
658 tokio::fs::write(&src, body).await.unwrap();
659 let events = vec![
660 r#"{"event":"init","operation":"upload","remote":"origin"}"#.to_owned(),
661 format!(
662 r#"{{"event":"upload","oid":"{oid}","size":{size},"path":"{path}"}}"#,
663 size = body.len(),
664 path = src.to_str().unwrap(),
665 ),
666 r#"{"event":"terminate"}"#.to_owned(),
667 ];
668 let (lines, res) = drive(&events, &resolver, tmp.path()).await;
669 res.expect("run completes");
670 assert!(store.contains(&format!("repo/lfs/{oid}")));
673 assert!(
675 lines
676 .iter()
677 .any(|l| l.contains(&format!(r#""oid":"{oid}""#))
678 && l.contains("\"event\":\"complete\""))
679 );
680 }
681}