forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.6 KiB
1//! Git side of backups: bundles of whole repos, bundle verification and the refs checksum.
2//! Every git call goes through [`GitCommand`]. Paths are chosen by the server, never by a user.
3
4use std::path::Path;
5
6use sha2::{Digest, Sha256};
7
8use crate::command::{GitCommand, GitEnv};
9use crate::error::{Error, Result};
10
11const REFS_FORMAT: &str = "--format=%(objectname) %(refname)";
12
13fn path_str(p: &Path) -> Result<&str> {
14 p.to_str()
15 .ok_or_else(|| Error::InvalidInput(format!("path is not valid UTF-8: {}", p.display())))
16}
17
18/// Output of `git for-each-ref --format='%(objectname) %(refname)'`, one line per ref.
19async fn refs_listing(env: &GitEnv, repo: &Path) -> Result<Vec<u8>> {
20 GitCommand::new(env, "for-each-ref")
21 .git_dir(repo)
22 .arg(REFS_FORMAT)
23 .output()
24 .await
25}
26
27/// sha256 (lowercase hex) of the sorted `for-each-ref` lines, each followed by `\n`. An empty
28/// repo gives the sha256 of the empty string. This is the reconcile checksum from architecture
29/// §8.2, recorded in the backup manifest as `refs_sha256`.
30pub async fn refs_checksum(env: &GitEnv, repo: &Path) -> Result<String> {
31 let out = refs_listing(env, repo).await?;
32 Ok(refs_checksum_of_listing(&out))
33}
34
35/// The same checksum as [`refs_checksum`], computed from `for-each-ref --format='%(objectname)
36/// %(refname)'` output that was obtained elsewhere (for example from a remote over ssh).
37pub fn refs_checksum_of_listing(listing: &[u8]) -> String {
38 let mut lines: Vec<&[u8]> = listing
39 .split(|b| *b == b'\n')
40 .filter(|l| !l.is_empty())
41 .collect();
42 lines.sort_unstable();
43 let mut hasher = Sha256::new();
44 for line in lines {
45 hasher.update(line);
46 hasher.update(b"\n");
47 }
48 hex::encode(hasher.finalize())
49}
50
51/// Writes `git bundle create <out> --all` for `repo`. `out` must not exist. Returns `Ok(None)`
52/// without writing anything when the repo has no refs, because git refuses to create an empty
53/// bundle. Returns `Ok(Some(()))` when the bundle was written. The caller must `bundle_verify` it.
54/// The written file is set to mode 0600.
55pub async fn bundle_create(env: &GitEnv, repo: &Path, out: &Path) -> Result<Option<()>> {
56 if std::fs::symlink_metadata(out).is_ok() {
57 return Err(Error::InvalidInput(format!(
58 "bundle target already exists: {}",
59 out.display()
60 )));
61 }
62 if refs_listing(env, repo)
63 .await?
64 .iter()
65 .all(|b| b.is_ascii_whitespace())
66 {
67 return Ok(None);
68 }
69 GitCommand::new(env, "bundle")
70 .git_dir(repo)
71 .arg("create")
72 .user_arg(path_str(out)?)?
73 .arg("--all")
74 .status()
75 .await?;
76 set_private_mode(out)?;
77 Ok(Some(()))
78}
79
80fn set_private_mode(path: &Path) -> Result<()> {
81 use std::os::unix::fs::PermissionsExt;
82 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
83 Ok(())
84}
85
86/// Verifies a bundle by really importing it, and returns the [`refs_checksum`] of what was
87/// imported. `scratch_repo` must be a fresh, empty bare repo that the caller created for this one
88/// bundle. The steps are:
89///
90/// 1. `git bundle verify`, which checks the header and prerequisites only. It does not notice a
91/// truncated pack, so it is not enough on its own.
92/// 2. `git fetch <bundle> +refs/*:refs/*` into the scratch repo, which reads and indexes the
93/// whole pack.
94/// 3. `git fsck --full` on the scratch repo, which checks every object it now holds.
95///
96/// Any failure is an error. The returned checksum must equal the `refs_sha256` recorded when the
97/// bundle was made.
98pub async fn bundle_verify(env: &GitEnv, scratch_repo: &Path, bundle: &Path) -> Result<String> {
99 let bundle_str = path_str(bundle)?;
100 GitCommand::new(env, "bundle")
101 .git_dir(scratch_repo)
102 .arg("verify")
103 .user_arg(bundle_str)?
104 .status()
105 .await?;
106 GitCommand::new(env, "fetch")
107 .git_dir(scratch_repo)
108 .arg("--quiet")
109 .user_arg(bundle_str)?
110 .user_arg("+refs/*:refs/*")?
111 .status()
112 .await?;
113 GitCommand::new(env, "fsck")
114 .git_dir(scratch_repo)
115 .arg("--full")
116 .status()
117 .await?;
118 refs_checksum(env, scratch_repo).await
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use std::process::Command;
125
126 fn env(dir: &Path) -> GitEnv {
127 GitEnv {
128 git_bin: "git".into(),
129 data_dir: dir.to_path_buf(),
130 }
131 }
132
133 fn git(dir: &Path, args: &[&str]) {
134 let out = Command::new("git")
135 .current_dir(dir)
136 .env("GIT_AUTHOR_NAME", "t")
137 .env("GIT_AUTHOR_EMAIL", "t@example.invalid")
138 .env("GIT_COMMITTER_NAME", "t")
139 .env("GIT_COMMITTER_EMAIL", "t@example.invalid")
140 .args(args)
141 .output()
142 .expect("run git");
143 assert!(out.status.success(), "git {args:?}: {out:?}");
144 }
145
146 /// A work repo with one commit, a branch and a tag. Returns its `.git` dir.
147 fn repo_with_refs(dir: &Path) -> std::path::PathBuf {
148 let work = dir.join("work");
149 std::fs::create_dir_all(&work).expect("mkdir");
150 git(&work, &["init", "-q", "-b", "main"]);
151 git(&work, &["commit", "-q", "--allow-empty", "-m", "one"]);
152 git(&work, &["branch", "feature"]);
153 git(&work, &["tag", "v1"]);
154 work.join(".git")
155 }
156
157 #[tokio::test]
158 async fn empty_repo_has_no_bundle_and_the_empty_checksum() {
159 let dir = tempfile::tempdir().expect("tempdir");
160 let env = env(dir.path());
161 let bare = dir.path().join("empty.git");
162 crate::repo::init_bare(&env, &bare, "main")
163 .await
164 .expect("init");
165 let out = dir.path().join("empty.bundle");
166 assert_eq!(
167 bundle_create(&env, &bare, &out).await.expect("create"),
168 None
169 );
170 assert!(!out.exists(), "nothing is written for an empty repo");
171 assert_eq!(
172 refs_checksum(&env, &bare).await.expect("checksum"),
173 hex::encode(Sha256::digest(b""))
174 );
175 }
176
177 #[tokio::test]
178 async fn bundle_of_a_repo_with_refs_verifies_and_checksum_is_sorted_sha256() {
179 let dir = tempfile::tempdir().expect("tempdir");
180 let env = env(dir.path());
181 let git_dir = repo_with_refs(dir.path());
182 let out = dir.path().join("r.bundle");
183 assert_eq!(
184 bundle_create(&env, &git_dir, &out).await.expect("create"),
185 Some(())
186 );
187 let scratch = dir.path().join("scratch.git");
188 crate::repo::init_bare(&env, &scratch, "main")
189 .await
190 .expect("scratch");
191 let imported = bundle_verify(&env, &scratch, &out).await.expect("verify");
192
193 let listing = refs_listing(&env, &git_dir).await.expect("list");
194 let mut lines: Vec<&str> = std::str::from_utf8(&listing)
195 .expect("utf8")
196 .lines()
197 .collect();
198 lines.sort_unstable();
199 let mut expected = Sha256::new();
200 for l in &lines {
201 expected.update(l.as_bytes());
202 expected.update(b"\n");
203 }
204 let source_sum = hex::encode(expected.finalize());
205 assert_eq!(
206 refs_checksum(&env, &git_dir).await.expect("checksum"),
207 source_sum
208 );
209 assert_eq!(imported, source_sum, "the imported refs match the source");
210 assert_eq!(lines.len(), 3, "main, feature and v1");
211
212 let mode = std::fs::metadata(&out).expect("meta").permissions();
213 use std::os::unix::fs::PermissionsExt;
214 assert_eq!(mode.mode() & 0o777, 0o600);
215 }
216
217 #[tokio::test]
218 async fn truncated_bundle_fails_verification() {
219 let dir = tempfile::tempdir().expect("tempdir");
220 let env = env(dir.path());
221 let git_dir = repo_with_refs(dir.path());
222 let out = dir.path().join("r.bundle");
223 bundle_create(&env, &git_dir, &out).await.expect("create");
224 let bytes = std::fs::read(&out).expect("read");
225 std::fs::write(&out, &bytes[..bytes.len() / 2]).expect("truncate");
226 let scratch = dir.path().join("scratch.git");
227 crate::repo::init_bare(&env, &scratch, "main")
228 .await
229 .expect("scratch");
230 assert!(bundle_verify(&env, &scratch, &out).await.is_err());
231 }
232
233 #[tokio::test]
234 async fn existing_bundle_target_is_refused() {
235 let dir = tempfile::tempdir().expect("tempdir");
236 let env = env(dir.path());
237 let git_dir = repo_with_refs(dir.path());
238 let out = dir.path().join("r.bundle");
239 std::fs::write(&out, b"x").expect("write");
240 assert!(bundle_create(&env, &git_dir, &out).await.is_err());
241 }
242}