Skip to main content

git_remote_object_store/lfs/
run.rs

1//! REPL driver for the LFS custom-transfer protocol.
2//!
3//! Generic over reader and writer so tests can drive it through
4//! `tokio::io::duplex`; the bin entrypoint wires real stdin/stdout.
5//!
6//! Stdout is the wire protocol — see `.claude/rules/protocol-stdout.md`.
7//! Diagnostic output goes through `tracing` (configured to write to
8//! stderr or a debug log file by the bin entrypoint).
9
10use 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/// Errors surfaced by [`run`] that are *fatal* to the agent process.
26///
27/// Backend / object-store errors that occur after init are not in
28/// here — they are folded into per-event `complete` payloads by the
29/// [`Agent`].
30#[derive(Debug, Error)]
31pub enum RunError {
32    /// Underlying transport (stdin/stdout) failed.
33    #[error("LFS protocol I/O error: {0}")]
34    Io(#[from] std::io::Error),
35    /// Agent dispatch error (transport or serialization).
36    #[error(transparent)]
37    Agent(#[from] AgentError),
38    /// An incoming line was not valid LFS JSON, or an outgoing event
39    /// could not be serialized. Either is fatal — the protocol cannot
40    /// continue past a parse mismatch.
41    #[error("malformed LFS event: {0}")]
42    MalformedEvent(#[from] serde_json::Error),
43    /// First event was not `init`. The LFS spec requires it. The
44    /// payload is the `Debug` rendering of the offending event,
45    /// captured at construction time.
46    #[error("expected init as the first event, got {0}")]
47    InitNotFirst(String),
48    /// Stdin closed before any event was read.
49    #[error("stdin closed before init")]
50    StdinClosed,
51}
52
53impl RunError {
54    /// `true` if this error is a `BrokenPipe` / `WriteZero` from
55    /// stdout closing — the bin-side REPL turns those into a clean
56    /// exit. Walks both the direct `Io` variant and the nested
57    /// `Agent(AgentError::Io)` variant produced by writes that flow
58    /// through the agent's [`write_event`][crate::lfs::agent::write_event].
59    #[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/// Init-time failures that the bin-side REPL converts into an LFS
75/// `init` error response and a clean exit. Distinct from [`RunError`]
76/// because none of these are fatal to the agent — they're reported on
77/// the wire as `{"error":{...}}`, then the loop returns `Ok(())`.
78#[derive(Debug, Error)]
79enum InitError {
80    /// `init.remote` was the empty string. Upstream's helper accepts
81    /// it and then explodes later; we reject up front.
82    #[error("init.remote is empty")]
83    EmptyRemote,
84    /// `git remote get-url` / URL parsing / backend construction
85    /// failed for the named remote.
86    #[error("cannot resolve remote \"{remote}\": {source}")]
87    Resolve {
88        /// Remote name from the init event.
89        remote: String,
90        /// Underlying resolver failure.
91        #[source]
92        source: Box<dyn std::error::Error + Send + Sync>,
93    },
94}
95
96/// How to resolve a remote name to an [`ObjectStore`]. Production
97/// uses a `gix`-based resolver; tests inject a closure that returns
98/// a `MockStore` (the in-memory test backend gated on `test-util`).
99#[async_trait::async_trait]
100pub trait RemoteResolver: Send + Sync {
101    /// Resolve `remote_name` → `(object store, optional bucket prefix)`.
102    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
108/// Production resolver: opens the local repo via `gix`, reads the
109/// remote URL, parses it, and builds the matching object-store
110/// backend.
111pub struct GitRemoteResolver {
112    /// Working directory of the local repository (cwd at process
113    /// start).
114    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        // `?` against `Box<dyn Error + Send + Sync>` uses the blanket
125        // `From<E: Error + Send + Sync + 'static> for Box<...>`, so no
126        // explicit cast is needed at each call site.
127        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        // LFS is engine-independent (objects live at `<prefix>/lfs/<oid>`
132        // regardless of the bundle/packchain choice); discard the
133        // resolved engine.
134        let (store, _engine) = backend::build(&parsed).await?;
135        Ok((store, prefix))
136    }
137}
138
139/// Drive the LFS REPL until stdin closes or `terminate` arrives.
140///
141/// `tmp_dir` is the destination directory for downloads
142/// (`<git-dir>/lfs/tmp`).
143///
144/// # Errors
145///
146/// Returns [`RunError::StdinClosed`] if stdin closes before the first event,
147/// [`RunError::MalformedEvent`] for unparseable JSON, or
148/// [`RunError::InitNotFirst`] if the first event is not `init`.
149/// Transport or serialisation errors from upload/download operations surface
150/// as [`RunError::Io`] or [`RunError::Agent`].
151pub 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            // Spec doesn't require ack on terminate; mirror upstream's
172            // silent exit.
173            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    // Malformed JSON is fatal — git-lfs never sends garbage on the
223    // wire. The `?` operator at call sites turns this into
224    // `RunError::MalformedEvent` via the `#[from]` impl.
225    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
250/// Validate `oid_raw` at the run-loop boundary. Returns `Some(oid)`
251/// on success (the caller dispatches into the agent), or `None`
252/// after emitting a `complete` event that echoes the raw rejected
253/// `oid_raw` in the wire `oid` field — so git-lfs can correlate the
254/// failure back to the pending transfer — with the validation error
255/// in `error.message`. The `op` label flows into the warn-log so an
256/// operator can correlate the rejection with the source event line.
257///
258/// `oid_raw` is wire-only: it is serde-escaped into the `complete`
259/// event and never used as a storage key. Only the validated
260/// [`LfsOid`] returned on the success path ever reaches a bucket key,
261/// so echoing the raw value carries no key-injection risk.
262async 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        // Pre-seed the second oid for download.
353        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        // Expected: init ack, progress+complete (upload), progress+complete (download).
382        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        // Regression guard: the variant carries a payload that has
433        // already been `Debug`-rendered by the caller, so the error
434        // message must use `{0}` (Display) over the wrapped String,
435        // not `{0:?}` which would double-quote the Debug form.
436        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        // Regression guard for InitError::EmptyRemote — upstream's
447        // helper accepts the empty string and explodes later; we
448        // reject up front and emit the structured error response.
449        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        // Regression guard: if stdout closes mid-init-ack, the bin
479        // turns the resulting error into a clean exit. RunError
480        // must classify it as `is_broken_pipe()` so the bin's
481        // `Err(other) if other.is_broken_pipe()` arm fires.
482        use tokio::io::duplex;
483
484        // A writer that returns BrokenPipe immediately. A `duplex`
485        // pair where the read half is dropped achieves this.
486        let (writer, reader) = duplex(64);
487        drop(reader); // force BrokenPipe on the next write
488
489        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    /// F-009: oid validation moved to the run-loop boundary. A
533    /// malformed oid on an `upload` event must surface as a
534    /// `complete` event that echoes the raw rejected oid in the wire
535    /// `oid` field (so git-lfs can correlate the failure back to the
536    /// pending transfer) with the validation error folded into
537    /// `error.message`.
538    #[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        // init ack + complete (error) for the upload.
560        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        // Byte-exact assertion on the wire format. The raw rejected
569        // oid is echoed in the `oid` field so git-lfs can correlate
570        // the failure with the pending transfer; the rejected string
571        // also appears (serde-escaped) in the error message.
572        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    /// F-009: the same shape for `download`. Validation lives in the
579    /// run loop, the agent is never reached, and the wire-format
580    /// failure event echoes the raw rejected oid just like the upload
581    /// case.
582    #[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        // Byte-exact assertion: the raw rejected oid is echoed in the
603        // wire `oid` field so git-lfs can correlate the failure, and
604        // the rejected string also appears in the error message.
605        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    /// F-009: a 64-char uppercase-hex oid trips the `NotLowerHex`
612    /// branch (correct length, wrong case) rather than the length
613    /// branch the short-oid cases above exercise. The exact bytes
614    /// emitted on the wire — including the verbatim uppercase oid in
615    /// the `oid` field — are pinned here.
616    #[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        // 64 uppercase hex chars: right length, wrong case.
625        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    /// F-009 sanity check: a valid oid passes the boundary check
644    /// and the agent is reached. This is covered by the existing
645    /// `full_round_trip_init_upload_download_terminate` test; this
646    /// shorter sibling pins the contract more directly.
647    #[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        // The bucket actually received the body — proof the agent
671        // was reached after boundary validation.
672        assert!(store.contains(&format!("repo/lfs/{oid}")));
673        // Wire-side complete has the validated oid (not empty).
674        assert!(
675            lines
676                .iter()
677                .any(|l| l.contains(&format!(r#""oid":"{oid}""#))
678                    && l.contains("\"event\":\"complete\""))
679        );
680    }
681}