git-ents.gitmain
⌘K
foforge
materialize.rs442 lines · 16.3 KB · rusthistorycomment on this file
1//! Checking out a git tree onto disk through gitoxide's `Find` seam alone
2//! (`arch.no-object-store-trait`) — no dependency on a real on-disk `.git`
3//! directory or a `git archive` subprocess, so this works identically
4//! against the in-memory fixture store in tests and a real odb in
5//! production.
6//!
7//! This is the one code path both the run loop's pushed-tree checkout and
8//! `ents-kiln`'s toolchain `materialize`'s `Embedded` case share
9//! (`effect.local-run`: "identical code path") — this module is `pub` so
10//! `ents-kiln` can call [`checkout`] directly across the crate boundary.
11//!
12//! Checkout runs on the *host*, before any sandbox exists, so it defends
13//! itself against a crafted tree (fsck-invalid but storable, and a trigger
14//! can match any pushed commit): entry names that could escape or collide
15//! inside `dest` — `.`, `..`, path separators, duplicates — are refused
16//! before anything is written ([`Error::UnsafeEntry`]), and within each
17//! tree symlink entries are written only after every other entry, so no
18//! write in the same checkout can be routed *through* a symlink the tree
19//! itself planted.
20
21use std::collections::HashSet;
22use std::path::Path;
23
24use gix_hash::ObjectId;
25use gix_object::bstr::ByteSlice as _;
26use gix_object::tree::EntryKind;
27use gix_object::{Find, Kind, TreeRef};
28
29use crate::error::{Error, Result};
30
31/// Whether `name` is a single, plain path component that cannot escape or
32/// alias its parent directory.
33fn safe_entry_name(name: &str) -> bool {
34 !name.is_empty()
35 && name != "."
36 && name != ".."
37 && !name.contains('/')
38 && !name.contains('\\')
39 && !name.contains('\0')
40}
41
42/// Recursively write `tree`'s entries under `dest`, which must already
43/// exist. Blob entries are written verbatim, with the executable bit set
44/// per the entry's mode; tree entries recurse into a created subdirectory;
45/// within each tree, symlink entries are written after every other entry
46/// (see the module doc for why).
47///
48/// # Errors
49///
50/// [`Error::UnsafeEntry`] for an entry name that could escape `dest` or
51/// duplicate an earlier entry; [`Error::Submodule`] for a gitlink entry
52/// (this design embeds no submodule content, `effect.toolchains`'s
53/// neighboring retention rule); [`Error::NotUtf8`] for a non-UTF-8
54/// filename; [`Error::Missing`] or [`Error::Decode`] for an unreadable
55/// object; [`Error::Io`] for a host filesystem failure. A symlink entry is
56/// written as a real symlink (`std::os::unix::fs::symlink`) pointing at
57/// its recorded target text.
58// @relation(effect.execution, scope=function)
59pub fn checkout(objects: &impl Find, tree: ObjectId, dest: &Path) -> Result<()> {
60 let mut buf = Vec::new();
61 let data = objects
62 .try_find(&tree, &mut buf)
63 .map_err(|source| Error::Decode {
64 oid: tree,
65 detail: source.to_string(),
66 })?
67 .ok_or(Error::Missing { oid: tree })?;
68 if data.kind != Kind::Tree {
69 return Err(Error::Decode {
70 oid: tree,
71 detail: "expected a tree".to_owned(),
72 });
73 }
74 let entries: Vec<(String, EntryKind, ObjectId)> = TreeRef::from_bytes(data.data, tree.kind())
75 .map_err(|e| Error::Decode {
76 oid: tree,
77 detail: e.to_string(),
78 })?
79 .entries
80 .iter()
81 .map(|entry| {
82 let name = entry
83 .filename
84 .to_str()
85 .map_err(|_not_utf8| Error::NotUtf8(dest.join(entry.filename.to_string())))?
86 .to_owned();
87 Ok((name, entry.mode.kind(), entry.oid.to_owned()))
88 })
89 .collect::<Result<Vec<_>>>()?;
90
91 let mut seen: HashSet<&str> = HashSet::with_capacity(entries.len());
92 for (name, _, _) in &entries {
93 if !safe_entry_name(name) {
94 return Err(Error::UnsafeEntry {
95 name: name.clone(),
96 detail: "not a plain single path component".to_owned(),
97 });
98 }
99 if !seen.insert(name.as_str()) {
100 return Err(Error::UnsafeEntry {
101 name: name.clone(),
102 detail: "duplicate entry in one tree".to_owned(),
103 });
104 }
105 }
106
107 // Symlinks last: nothing else written by this checkout can be routed
108 // through a link the same tree planted.
109 let (links, others): (Vec<_>, Vec<_>) = entries
110 .into_iter()
111 .partition(|(_, kind, _)| *kind == EntryKind::Link);
112
113 for (name, kind, oid) in others.into_iter().chain(links) {
114 let path = dest.join(&name);
115 match kind {
116 EntryKind::Tree => {
117 make_dir(&path)?;
118 checkout(objects, oid, &path)?;
119 }
120 EntryKind::Commit => {
121 return Err(Error::Submodule { path: name });
122 }
123 EntryKind::Link => {
124 let mut buf = Vec::new();
125 let data = objects
126 .try_find(&oid, &mut buf)
127 .map_err(|source| Error::Decode {
128 oid,
129 detail: source.to_string(),
130 })?
131 .ok_or(Error::Missing { oid })?;
132 let target = std::str::from_utf8(data.data)
133 .map_err(|_not_utf8| Error::NotUtf8(path.clone()))?;
134 symlink(target, &path)?;
135 }
136 EntryKind::Blob | EntryKind::BlobExecutable => {
137 let mut buf = Vec::new();
138 let data = objects
139 .try_find(&oid, &mut buf)
140 .map_err(|source| Error::Decode {
141 oid,
142 detail: source.to_string(),
143 })?
144 .ok_or(Error::Missing { oid })?;
145 std::fs::write(&path, data.data).map_err(|source| Error::Io {
146 path: path.clone(),
147 source,
148 })?;
149 if kind == EntryKind::BlobExecutable {
150 set_executable(&path)?;
151 }
152 }
153 }
154 }
155 Ok(())
156}
157
158fn make_dir(path: &Path) -> Result<()> {
159 std::fs::create_dir_all(path).map_err(|source| Error::Io {
160 path: path.to_owned(),
161 source,
162 })
163}
164
165#[cfg(unix)]
166fn set_executable(path: &Path) -> Result<()> {
167 use std::os::unix::fs::PermissionsExt as _;
168 let mut perms = std::fs::metadata(path)
169 .map_err(|source| Error::Io {
170 path: path.to_owned(),
171 source,
172 })?
173 .permissions();
174 perms.set_mode(0o755);
175 std::fs::set_permissions(path, perms).map_err(|source| Error::Io {
176 path: path.to_owned(),
177 source,
178 })
179}
180
181#[cfg(not(unix))]
182fn set_executable(_path: &Path) -> Result<()> {
183 Ok(())
184}
185
186#[cfg(unix)]
187fn symlink(target: &str, path: &Path) -> Result<()> {
188 std::os::unix::fs::symlink(target, path).map_err(|source| Error::Io {
189 path: path.to_owned(),
190 source,
191 })
192}
193
194#[cfg(not(unix))]
195fn symlink(target: &str, path: &Path) -> Result<()> {
196 std::fs::write(path, target).map_err(|source| Error::Io {
197 path: path.to_owned(),
198 source,
199 })
200}
201
202#[cfg(test)]
203mod tests {
204 #![allow(clippy::expect_used, reason = "unit test")]
205
206 use ents_testutil::ObjectStore;
207 use gix_object::tree::{Entry, EntryMode};
208 use gix_object::{Kind, Tree, Write as _};
209
210 use super::*;
211
212 #[test]
213 // @relation(effect.toolchains, effect.execution, scope=function, role=Verifies)
214 fn checkout_writes_blobs_and_sets_the_executable_bit() {
215 let objects = ObjectStore::default();
216 let script = objects
217 .write_buf(Kind::Blob, b"#!/bin/sh\necho hi\n")
218 .expect("write");
219 let readme = objects.write_buf(Kind::Blob, b"hello\n").expect("write");
220 let tree = Tree {
221 entries: vec![
222 Entry {
223 mode: EntryMode::from(EntryKind::Blob),
224 filename: "README".into(),
225 oid: readme,
226 },
227 Entry {
228 mode: EntryMode::from(EntryKind::BlobExecutable),
229 filename: "run.sh".into(),
230 oid: script,
231 },
232 ],
233 };
234 let tree_oid = objects.write(&tree).expect("write tree");
235
236 let dir = tempfile::tempdir().expect("tempdir");
237 checkout(&objects, tree_oid, dir.path()).expect("checkout");
238
239 let script_path = dir.path().join("run.sh");
240 assert_eq!(
241 std::fs::read_to_string(&script_path).expect("read"),
242 "#!/bin/sh\necho hi\n"
243 );
244 #[cfg(unix)]
245 {
246 use std::os::unix::fs::PermissionsExt as _;
247 let mode = std::fs::metadata(&script_path)
248 .expect("stat")
249 .permissions()
250 .mode();
251 assert_eq!(mode & 0o111, 0o111, "run.sh must be executable");
252 }
253 assert_eq!(
254 std::fs::read_to_string(dir.path().join("README")).expect("read"),
255 "hello\n"
256 );
257 }
258
259 #[test]
260 // @relation(effect.toolchains, scope=function, role=Verifies)
261 fn checkout_recurses_into_subdirectories() {
262 let objects = ObjectStore::default();
263 let leaf = objects.write_buf(Kind::Blob, b"leaf\n").expect("write");
264 let inner = Tree {
265 entries: vec![Entry {
266 mode: EntryMode::from(EntryKind::Blob),
267 filename: "leaf.txt".into(),
268 oid: leaf,
269 }],
270 };
271 let inner_oid = objects.write(&inner).expect("write inner tree");
272 let outer = Tree {
273 entries: vec![Entry {
274 mode: EntryMode::from(EntryKind::Tree),
275 filename: "sub".into(),
276 oid: inner_oid,
277 }],
278 };
279 let outer_oid = objects.write(&outer).expect("write outer tree");
280
281 let dir = tempfile::tempdir().expect("tempdir");
282 checkout(&objects, outer_oid, dir.path()).expect("checkout");
283
284 assert_eq!(
285 std::fs::read_to_string(dir.path().join("sub").join("leaf.txt")).expect("read"),
286 "leaf\n"
287 );
288 }
289
290 #[test]
291 // @relation(effect.toolchains, scope=function, role=Verifies)
292 fn checkout_refuses_a_submodule_entry() {
293 let objects = ObjectStore::default();
294 let tree = Tree {
295 entries: vec![Entry {
296 mode: EntryMode::from(EntryKind::Commit),
297 filename: "vendor".into(),
298 oid: ObjectId::null(gix_hash::Kind::Sha1),
299 }],
300 };
301 let tree_oid = objects.write(&tree).expect("write tree");
302
303 let dir = tempfile::tempdir().expect("tempdir");
304 let err = checkout(&objects, tree_oid, dir.path()).expect_err("must refuse a gitlink");
305 assert!(matches!(err, Error::Submodule { .. }));
306 }
307
308 #[test]
309 // @relation(effect.execution, scope=function, role=Verifies)
310 fn checkout_writes_a_symlink_entry_with_its_recorded_target() {
311 let objects = ObjectStore::default();
312 let target = objects.write_buf(Kind::Blob, b"README").expect("write");
313 let tree = Tree {
314 entries: vec![Entry {
315 mode: EntryMode::from(EntryKind::Link),
316 filename: "link".into(),
317 oid: target,
318 }],
319 };
320 let tree_oid = objects.write(&tree).expect("write tree");
321
322 let dir = tempfile::tempdir().expect("tempdir");
323 checkout(&objects, tree_oid, dir.path()).expect("checkout");
324
325 #[cfg(unix)]
326 assert_eq!(
327 std::fs::read_link(dir.path().join("link")).expect("is a symlink"),
328 std::path::PathBuf::from("README")
329 );
330 }
331
332 /// One raw (git wire format) tree entry — the fsck-invalid trees these
333 /// tests need cannot be built through gitoxide's own `Tree` writer,
334 /// which validates sorting; the attack ships bytes, so the tests do
335 /// too.
336 fn raw_entry(mode: &str, name: &[u8], oid: &ObjectId) -> Vec<u8> {
337 let mut entry = Vec::new();
338 entry.extend_from_slice(mode.as_bytes());
339 entry.push(b' ');
340 entry.extend_from_slice(name);
341 entry.push(0);
342 entry.extend_from_slice(oid.as_bytes());
343 entry
344 }
345
346 #[rstest::rstest]
347 #[case::parent_dir(b"..".as_slice())]
348 #[case::current_dir(b".".as_slice())]
349 #[case::path_separator(b"a/b".as_slice())]
350 #[case::backslash(b"a\\b".as_slice())]
351 // @relation(effect.execution, scope=function, role=Verifies)
352 fn checkout_refuses_an_entry_name_that_could_escape_the_destination(#[case] name: &[u8]) {
353 let objects = ObjectStore::default();
354 let blob = objects.write_buf(Kind::Blob, b"owned\n").expect("write");
355 let tree_oid = objects
356 .write_buf(Kind::Tree, &raw_entry("100644", name, &blob))
357 .expect("a crafted tree is storable even though fsck-invalid");
358
359 let dir = tempfile::tempdir().expect("tempdir");
360 let err = checkout(&objects, tree_oid, dir.path())
361 .expect_err("host-side checkout must refuse a traversal-shaped name");
362 assert!(matches!(err, Error::UnsafeEntry { .. }), "got {err:?}");
363 // Nothing may have been written before the refusal.
364 assert_eq!(
365 std::fs::read_dir(dir.path()).expect("readable").count(),
366 0,
367 "the refusal must come before any write"
368 );
369 }
370
371 #[test]
372 // @relation(effect.execution, scope=function, role=Verifies)
373 fn checkout_refuses_duplicate_entries_in_one_tree() {
374 // The concrete attack: a symlink named `sub` pointing outside the
375 // destination, then a tree entry also named `sub` — without the
376 // duplicate check, the recursion would write through the link.
377 let objects = ObjectStore::default();
378 let link_target = objects.write_buf(Kind::Blob, b"/tmp").expect("write");
379 let payload = objects.write_buf(Kind::Blob, b"escaped\n").expect("write");
380 let inner = Tree {
381 entries: vec![Entry {
382 mode: EntryMode::from(EntryKind::Blob),
383 filename: "payload".into(),
384 oid: payload,
385 }],
386 };
387 let inner_oid = objects.write(&inner).expect("write inner tree");
388
389 let mut raw = raw_entry("120000", b"sub", &link_target);
390 raw.extend_from_slice(&raw_entry("40000", b"sub", &inner_oid));
391 let tree_oid = objects
392 .write_buf(Kind::Tree, &raw)
393 .expect("a crafted tree is storable even though fsck-invalid");
394
395 let dir = tempfile::tempdir().expect("tempdir");
396 let err =
397 checkout(&objects, tree_oid, dir.path()).expect_err("duplicate names must be refused");
398 assert!(matches!(err, Error::UnsafeEntry { .. }), "got {err:?}");
399 }
400
401 #[test]
402 // @relation(effect.execution, scope=function, role=Verifies)
403 fn checkout_writes_symlinks_after_every_other_entry() {
404 // A symlink sorted before a blob in the raw bytes must still be
405 // created after it — the ordering is behavioral, not cosmetic: it
406 // is what guarantees no later write in the same checkout can be
407 // routed through a link the tree planted.
408 let objects = ObjectStore::default();
409 let link_target = objects.write_buf(Kind::Blob, b"z-file").expect("write");
410 let blob = objects.write_buf(Kind::Blob, b"content\n").expect("write");
411 let tree = Tree {
412 entries: vec![
413 Entry {
414 mode: EntryMode::from(EntryKind::Link),
415 filename: "a-link".into(),
416 oid: link_target,
417 },
418 Entry {
419 mode: EntryMode::from(EntryKind::Blob),
420 filename: "z-file".into(),
421 oid: blob,
422 },
423 ],
424 };
425 let tree_oid = objects.write(&tree).expect("write tree");
426
427 let dir = tempfile::tempdir().expect("tempdir");
428 checkout(&objects, tree_oid, dir.path()).expect("checkout");
429
430 // Had the link been written first and the blob written through it,
431 // reading via the link and via the file would still agree — so
432 // assert on the filesystem's own record instead: the link must be
433 // a symlink, and the file a regular file, each with its own bytes.
434 let link_meta = std::fs::symlink_metadata(dir.path().join("a-link")).expect("stat");
435 assert!(link_meta.file_type().is_symlink());
436 assert_eq!(
437 std::fs::read_to_string(dir.path().join("z-file")).expect("read"),
438 "content\n"
439 );
440 }
441
442}