1use std::io::{self, Read, Write};
4
5use git_lfs_pointer::Pointer;
6use git_lfs_store::Store;
7
8use crate::FetchError;
9use crate::detect_pointer;
10
11#[derive(Debug)]
13pub enum SmudgeOutcome {
14 Passthrough,
19 Resolved(Pointer),
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum SmudgeError {
26 #[error(transparent)]
27 Io(#[from] io::Error),
28 #[error("object {} (size {}) is not present in the local store", .0.oid, .0.size)]
32 ObjectMissing(Pointer),
33 #[error("fetch failed: {0}")]
36 FetchFailed(FetchError),
37 #[error("pointer extensions are not yet supported")]
39 ExtensionsUnsupported,
40}
41
42pub fn smudge<R: Read, W: Write>(
50 store: &Store,
51 input: &mut R,
52 output: &mut W,
53) -> Result<SmudgeOutcome, SmudgeError> {
54 let (head, maybe_pointer) = detect_pointer(input)?;
55
56 let Some(pointer) = maybe_pointer else {
57 output.write_all(&head)?;
59 io::copy(input, output)?;
60 return Ok(SmudgeOutcome::Passthrough);
61 };
62
63 if pointer.is_empty() {
64 return Ok(SmudgeOutcome::Resolved(pointer));
65 }
66
67 if !pointer.extensions.is_empty() {
68 return Err(SmudgeError::ExtensionsUnsupported);
69 }
70
71 if !store.contains_with_size(pointer.oid, pointer.size) {
75 return Err(SmudgeError::ObjectMissing(pointer));
76 }
77
78 let mut file = store.open(pointer.oid)?;
79 io::copy(&mut file, output)?;
80 Ok(SmudgeOutcome::Resolved(pointer))
81}
82
83pub fn smudge_with_fetch<R, W, F>(
95 store: &Store,
96 input: &mut R,
97 output: &mut W,
98 mut fetch: F,
99) -> Result<SmudgeOutcome, SmudgeError>
100where
101 R: Read,
102 W: Write,
103 F: FnMut(&Pointer) -> Result<(), FetchError>,
104{
105 match smudge(store, input, output) {
106 Err(SmudgeError::ObjectMissing(pointer)) => {
107 fetch(&pointer).map_err(SmudgeError::FetchFailed)?;
108 if !store.contains_with_size(pointer.oid, pointer.size) {
109 return Err(SmudgeError::ObjectMissing(pointer));
110 }
111 let mut file = store.open(pointer.oid)?;
112 io::copy(&mut file, output)?;
113 Ok(SmudgeOutcome::Resolved(pointer))
114 }
115 other => other,
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::clean;
123 use git_lfs_pointer::VERSION_LATEST;
124 use tempfile::TempDir;
125
126 fn fixture() -> (TempDir, Store) {
127 let tmp = TempDir::new().unwrap();
128 let store = Store::new(tmp.path().join("lfs"));
129 (tmp, store)
130 }
131
132 fn run(store: &Store, input: &[u8]) -> (Result<SmudgeOutcome, SmudgeError>, Vec<u8>) {
133 let mut out = Vec::new();
134 let outcome = smudge(store, &mut { input }, &mut out);
135 (outcome, out)
136 }
137
138 fn clean_into(store: &Store, content: &[u8]) -> Vec<u8> {
140 let mut out = Vec::new();
141 clean(store, &mut { content }, &mut out).unwrap();
142 out
143 }
144
145 #[test]
148 fn pointer_resolves_from_store() {
149 let (_t, store) = fixture();
150 let content = b"smudge a\n";
151 let pointer_text = clean_into(&store, content);
152
153 let (outcome, out) = run(&store, &pointer_text);
154 let p = match outcome.unwrap() {
155 SmudgeOutcome::Resolved(p) => p,
156 o => panic!("expected Resolved, got {o:?}"),
157 };
158 assert_eq!(p.size, content.len() as u64);
159 assert_eq!(out, content);
160 }
161
162 #[test]
163 fn empty_pointer_writes_nothing() {
164 let (_t, store) = fixture();
165 let (outcome, out) = run(&store, b"");
166 match outcome.unwrap() {
167 SmudgeOutcome::Resolved(p) => assert!(p.is_empty()),
168 o => panic!("expected Resolved(empty), got {o:?}"),
169 }
170 assert!(out.is_empty());
171 }
172
173 #[test]
174 fn clean_smudge_round_trip_preserves_bytes() {
175 let (_t, store) = fixture();
176 for content in [
177 &b""[..],
178 &b"hello"[..],
179 &b"binary \x00\x01\xff data"[..],
180 &(0..256u16).map(|i| i as u8).collect::<Vec<_>>(),
181 ] {
182 let pointer_text = clean_into(&store, content);
183 let mut out = Vec::new();
184 smudge(&store, &mut { &pointer_text[..] }, &mut out).unwrap();
185 assert_eq!(out, content, "round-trip failed for {content:?}");
186 }
187 }
188
189 #[test]
192 fn invalid_pointer_passes_through_short() {
193 let (_t, store) = fixture();
194 for input in [&b"wat"[..], b"not a git-lfs file", b"version "] {
195 let (outcome, out) = run(&store, input);
196 assert!(matches!(outcome.unwrap(), SmudgeOutcome::Passthrough));
197 assert_eq!(out, input);
198 }
199 }
200
201 #[test]
202 fn long_non_pointer_passes_through() {
203 let (_t, store) = fixture();
205 let content: Vec<u8> = (0..2048u32).map(|i| (i ^ (i >> 3)) as u8).collect();
206 let (outcome, out) = run(&store, &content);
207 assert!(matches!(outcome.unwrap(), SmudgeOutcome::Passthrough));
208 assert_eq!(out, content);
209 }
210
211 #[test]
214 fn missing_object_errors() {
215 let (_t, store) = fixture();
216 let unknown_oid = "0000000000000000000000000000000000000000000000000000000000000001";
217 let pointer_text = format!("version {VERSION_LATEST}\noid sha256:{unknown_oid}\nsize 5\n");
218 let (outcome, out) = run(&store, pointer_text.as_bytes());
219 match outcome.unwrap_err() {
220 SmudgeError::ObjectMissing(pointer) => {
221 assert_eq!(pointer.oid.to_string(), unknown_oid);
222 assert_eq!(pointer.size, 5);
223 }
224 e => panic!("expected ObjectMissing, got {e:?}"),
225 }
226 assert!(out.is_empty(), "no partial output on miss");
227 }
228
229 #[test]
230 fn size_mismatch_treated_as_missing() {
231 let (_t, store) = fixture();
232 let pointer_text = clean_into(&store, b"abc"); let tampered = String::from_utf8(pointer_text)
236 .unwrap()
237 .replace("size 3", "size 99");
238 let (outcome, _) = run(&store, tampered.as_bytes());
239 match outcome.unwrap_err() {
240 SmudgeError::ObjectMissing(p) => assert_eq!(p.size, 99),
241 e => panic!("expected ObjectMissing, got {e:?}"),
242 }
243 }
244
245 #[test]
248 fn fetch_populates_store_then_streams() {
249 let (_t, store) = fixture();
250 let content = b"to be fetched\n";
251 let pointer_text = clean_into(&store, content);
255 let parsed = git_lfs_pointer::Pointer::parse(&pointer_text).unwrap();
257 std::fs::remove_file(store.object_path(parsed.oid)).unwrap();
258 assert!(!store.contains(parsed.oid));
259
260 let mut out = Vec::new();
261 let store_ref = &store;
262 let outcome = smudge_with_fetch(
263 &store,
264 &mut { &pointer_text[..] },
265 &mut out,
266 |p: &Pointer| {
267 store_ref.insert(&mut { &content[..] }).unwrap();
269 assert_eq!(p.size, content.len() as u64);
270 Ok(())
271 },
272 );
273 assert!(matches!(outcome.unwrap(), SmudgeOutcome::Resolved(_)));
274 assert_eq!(out, content);
275 }
276
277 #[test]
278 fn fetch_failure_surfaces_as_fetch_failed() {
279 let (_t, store) = fixture();
280 let unknown = "0000000000000000000000000000000000000000000000000000000000000001";
281 let pointer_text =
282 format!("version {VERSION_LATEST}\noid sha256:{unknown}\nsize 5\n");
283 let mut out = Vec::new();
284 let outcome = smudge_with_fetch(
285 &store,
286 &mut { pointer_text.as_bytes() },
287 &mut out,
288 |_p: &Pointer| Err("server is on fire".into()),
289 );
290 match outcome.unwrap_err() {
291 SmudgeError::FetchFailed(e) => {
292 assert!(e.to_string().contains("server is on fire"));
293 }
294 other => panic!("expected FetchFailed, got {other:?}"),
295 }
296 assert!(out.is_empty());
297 }
298
299 #[test]
300 fn fetch_returning_ok_but_not_inserting_still_errors() {
301 let (_t, store) = fixture();
303 let unknown = "0000000000000000000000000000000000000000000000000000000000000001";
304 let pointer_text =
305 format!("version {VERSION_LATEST}\noid sha256:{unknown}\nsize 5\n");
306 let mut out = Vec::new();
307 let outcome = smudge_with_fetch(
308 &store,
309 &mut { pointer_text.as_bytes() },
310 &mut out,
311 |_p: &Pointer| Ok(()),
312 );
313 assert!(matches!(
314 outcome.unwrap_err(),
315 SmudgeError::ObjectMissing(_)
316 ));
317 }
318
319 #[test]
320 fn fetch_not_invoked_when_object_already_present() {
321 let (_t, store) = fixture();
322 let content = b"already here";
323 let pointer_text = clean_into(&store, content);
324 let mut out = Vec::new();
325 let mut calls = 0;
326 smudge_with_fetch(
327 &store,
328 &mut { &pointer_text[..] },
329 &mut out,
330 |_p: &Pointer| {
331 calls += 1;
332 Ok(())
333 },
334 )
335 .unwrap();
336 assert_eq!(calls, 0, "fetch must not be called when store has the object");
337 assert_eq!(out, content);
338 }
339
340 #[test]
341 fn extensions_are_not_yet_supported() {
342 let (_t, store) = fixture();
343 let oid_hex = "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393";
344 let ext_oid = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
345 let pointer_text = format!(
346 "version {VERSION_LATEST}\n\
347 ext-0-foo sha256:{ext_oid}\n\
348 oid sha256:{oid_hex}\n\
349 size 12345\n",
350 );
351 let (outcome, _) = run(&store, pointer_text.as_bytes());
352 assert!(matches!(
353 outcome.unwrap_err(),
354 SmudgeError::ExtensionsUnsupported
355 ));
356 }
357}