Skip to main content

zub/object/
tree.rs

1use std::fs::{self, File};
2use std::io::Write;
3use std::path::PathBuf;
4
5use sha2::{Digest, Sha256};
6
7use crate::error::{Error, IoResultExt, Result};
8use crate::hash::Hash;
9use crate::repo::Repo;
10use crate::types::Tree;
11
12/// write a tree to the object store
13///
14/// trees are serialized as CBOR, then zstd compressed.
15/// the hash is computed over the compressed bytes.
16pub fn write_tree(repo: &Repo, tree: &Tree) -> Result<Hash> {
17    // serialize to cbor
18    let mut cbor_bytes = Vec::new();
19    ciborium::into_writer(tree, &mut cbor_bytes)?;
20
21    // compress with zstd (level 3 - fast, reasonable ratio)
22    let compressed = zstd::encode_all(&cbor_bytes[..], 3).map_err(|e| Error::Io {
23        path: PathBuf::from("<zstd>"),
24        source: e,
25    })?;
26
27    // hash the compressed bytes
28    let hash = Hash::from_bytes(Sha256::digest(&compressed).into());
29
30    let (dir, file) = hash.to_path_components();
31    let tree_dir = repo.trees_path().join(&dir);
32    let tree_path = tree_dir.join(&file);
33
34    // dedup: if tree already exists, we're done
35    if tree_path.exists() {
36        return Ok(hash);
37    }
38
39    // ensure directory exists
40    fs::create_dir_all(&tree_dir).with_path(&tree_dir)?;
41
42    // atomic write: temp -> fsync -> rename
43    let tmp_path = repo.tmp_path().join(uuid::Uuid::new_v4().to_string());
44    {
45        let mut tmp_file = File::create(&tmp_path).with_path(&tmp_path)?;
46        tmp_file.write_all(&compressed).with_path(&tmp_path)?;
47        tmp_file.sync_all().with_path(&tmp_path)?;
48    }
49
50    // rename to final location
51    fs::rename(&tmp_path, &tree_path).with_path(&tree_path)?;
52
53    // fsync parent directory
54    let dir_file = File::open(&tree_dir).with_path(&tree_dir)?;
55    dir_file.sync_all().with_path(&tree_dir)?;
56
57    Ok(hash)
58}
59
60/// read a tree from the object store
61pub fn read_tree(repo: &Repo, hash: &Hash) -> Result<Tree> {
62    let path = tree_path(repo, hash);
63
64    let compressed = fs::read(&path).map_err(|e| {
65        if e.kind() == std::io::ErrorKind::NotFound {
66            Error::ObjectNotFound(*hash)
67        } else {
68            Error::Io {
69                path: path.clone(),
70                source: e,
71            }
72        }
73    })?;
74
75    // verify hash
76    let actual_hash = Hash::from_bytes(Sha256::digest(&compressed).into());
77    if actual_hash != *hash {
78        return Err(Error::CorruptObject(*hash));
79    }
80
81    // decompress
82    let cbor_bytes = zstd::decode_all(&compressed[..]).map_err(|e| Error::Io {
83        path: path.clone(),
84        source: e,
85    })?;
86
87    // deserialize
88    let tree: Tree = ciborium::from_reader(&cbor_bytes[..])?;
89
90    Ok(tree)
91}
92
93/// get the filesystem path to a tree object
94pub fn tree_path(repo: &Repo, hash: &Hash) -> PathBuf {
95    let (dir, file) = hash.to_path_components();
96    repo.trees_path().join(dir).join(file)
97}
98
99/// check if a tree exists in the object store
100#[allow(dead_code)]
101pub fn tree_exists(repo: &Repo, hash: &Hash) -> bool {
102    tree_path(repo, hash).exists()
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::types::{EntryKind, TreeEntry};
109    use tempfile::tempdir;
110
111    fn test_repo() -> (tempfile::TempDir, Repo) {
112        let dir = tempdir().unwrap();
113        let repo_path = dir.path().join("repo");
114        let repo = Repo::init(&repo_path).unwrap();
115        (dir, repo)
116    }
117
118    #[test]
119    fn test_write_and_read_tree() {
120        let (_dir, repo) = test_repo();
121
122        let entries = vec![
123            TreeEntry::new("file.txt", EntryKind::regular(Hash::ZERO, 100)),
124            TreeEntry::new("subdir", EntryKind::directory(Hash::ZERO, 0, 0, 0o755)),
125        ];
126        let tree = Tree::new(entries).unwrap();
127
128        let hash = write_tree(&repo, &tree).unwrap();
129        assert!(tree_exists(&repo, &hash));
130
131        let read_tree = read_tree(&repo, &hash).unwrap();
132        assert_eq!(tree, read_tree);
133    }
134
135    #[test]
136    fn test_tree_deduplication() {
137        let (_dir, repo) = test_repo();
138
139        let entries = vec![TreeEntry::new("foo", EntryKind::regular(Hash::ZERO, 50))];
140        let tree = Tree::new(entries).unwrap();
141
142        let h1 = write_tree(&repo, &tree).unwrap();
143        let h2 = write_tree(&repo, &tree).unwrap();
144
145        assert_eq!(h1, h2);
146    }
147
148    #[test]
149    fn test_empty_tree() {
150        let (_dir, repo) = test_repo();
151
152        let tree = Tree::empty();
153        let hash = write_tree(&repo, &tree).unwrap();
154
155        let read_tree = read_tree(&repo, &hash).unwrap();
156        assert!(read_tree.is_empty());
157    }
158
159    #[test]
160    fn test_read_nonexistent_tree() {
161        let (_dir, repo) = test_repo();
162
163        let fake_hash =
164            Hash::from_hex("1111111111111111111111111111111111111111111111111111111111111111")
165                .unwrap();
166        let result = read_tree(&repo, &fake_hash);
167
168        assert!(matches!(result, Err(Error::ObjectNotFound(_))));
169    }
170
171    #[test]
172    fn test_tree_with_all_entry_types() {
173        let (_dir, repo) = test_repo();
174
175        let entries = vec![
176            TreeEntry::new("regular", EntryKind::regular(Hash::ZERO, 100)),
177            TreeEntry::new("symlink", EntryKind::symlink(Hash::ZERO)),
178            TreeEntry::new("dir", EntryKind::directory(Hash::ZERO, 1000, 1000, 0o755)),
179            TreeEntry::new(
180                "block",
181                EntryKind::BlockDevice {
182                    major: 8,
183                    minor: 0,
184                    uid: 0,
185                    gid: 6,
186                    mode: 0o660,
187                    xattrs: vec![],
188                },
189            ),
190            TreeEntry::new(
191                "char",
192                EntryKind::CharDevice {
193                    major: 1,
194                    minor: 3,
195                    uid: 0,
196                    gid: 0,
197                    mode: 0o666,
198                    xattrs: vec![],
199                },
200            ),
201            TreeEntry::new(
202                "fifo",
203                EntryKind::Fifo {
204                    uid: 0,
205                    gid: 0,
206                    mode: 0o644,
207                    xattrs: vec![],
208                },
209            ),
210            TreeEntry::new("hardlink", EntryKind::hardlink("regular")),
211        ];
212
213        let tree = Tree::new(entries).unwrap();
214        let hash = write_tree(&repo, &tree).unwrap();
215
216        let read_tree = read_tree(&repo, &hash).unwrap();
217        assert_eq!(tree.len(), read_tree.len());
218
219        // verify all entries came back
220        assert!(read_tree.get("regular").is_some());
221        assert!(read_tree.get("symlink").is_some());
222        assert!(read_tree.get("dir").is_some());
223        assert!(read_tree.get("block").is_some());
224        assert!(read_tree.get("char").is_some());
225        assert!(read_tree.get("fifo").is_some());
226        assert!(read_tree.get("hardlink").is_some());
227    }
228}