anvilsign in

collin/anvil

1//! The attachments side ref: `refs/anvil/attachments`.
2//!
3//! Attachments are uploaded through the web UI and stored canonically on disk
4//! (see `anvil-core`'s `attachments`), never in git history. To let a clone
5//! pull them *without* a separate credential — git transport already carries
6//! its own auth — anvil mirrors each stored blob into a single ref outside the
7//! branch namespace: a commit whose flat tree maps `<sha256> → blob`.
8//!
9//! Because it isn't a branch or tag, a default `git fetch` never touches it; a
10//! client opts in with an explicit refspec, then reads a blob by hash with
11//! `git cat-file -p refs/anvil/attachments:<sha256>`.
12
13use std::path::Path;
14
15use gix::objs::tree;
16
17use crate::error::{
18 Error,
19 Result,
20};
21
22/// The ref anvil mirrors attachments into. Off the branch/tag namespaces, so
23/// it never rides a default pull.
24pub const REF: &str = "refs/anvil/attachments";
25
26fn read(e: impl std::fmt::Display) -> Error {
27 Error::Read(e.to_string())
28}
29
30/// Mirror one attachment (`hash` → `content`) into [`REF`], creating or
31/// advancing the ref. Idempotent: re-adding the same hash with the same bytes
32/// is a no-op commit-wise only if nothing changed, but is always safe to call.
33/// The ref is server-owned, so the update is an unconditional force (no CAS).
34pub fn add(repo_path: &Path, hash: &str, content: &[u8]) -> Result<()> {
35 let repo = gix::open(repo_path).map_err(read)?;
36
37 // Current ref tip (if any) and its tree, else start empty.
38 let parent = crate::browse::resolve_commit(repo_path, REF)
39 .ok()
40 .and_then(|hex| gix::ObjectId::from_hex(hex.as_bytes()).ok());
41 let mut tree: gix::objs::Tree = match parent {
42 Some(commit_id) => {
43 let tree_id = repo
44 .find_object(commit_id)
45 .map_err(read)?
46 .peel_to_commit()
47 .map_err(read)?
48 .tree_id()
49 .map_err(read)?
50 .detach();
51 let obj = repo.find_object(tree_id).map_err(read)?;
52 gix::objs::TreeRef::from_bytes(&obj.data, gix::hash::Kind::Sha1)
53 .map_err(read)?
54 .into()
55 }
56 None => gix::objs::Tree {
57 entries: Vec::new(),
58 },
59 };
60
61 let blob_id = repo.write_blob(content).map_err(read)?.detach();
62 match tree.entries.iter_mut().find(|e| e.filename == hash) {
63 Some(entry) => {
64 if entry.oid == blob_id {
65 return Ok(()); // already mirrored, identical bytes
66 }
67 entry.oid = blob_id;
68 }
69 None => tree.entries.push(tree::Entry {
70 mode: tree::EntryKind::Blob.into(),
71 filename: hash.into(),
72 oid: blob_id,
73 }),
74 }
75 // git requires tree entries sorted by name; the entries are all blobs
76 // (flat tree), so a plain filename sort matches git's ordering.
77 tree.entries.sort();
78 let tree_id = repo.write_object(&tree).map_err(read)?.detach();
79
80 let sig = gix::actor::Signature {
81 name: "anvil".into(),
82 email: "anvil@localhost".into(),
83 time: gix::date::Time::now_local_or_utc(),
84 };
85 let commit = gix::objs::Commit {
86 tree: tree_id,
87 parents: parent.into_iter().collect(),
88 author: sig.clone(),
89 committer: sig,
90 encoding: None,
91 message: format!("attachment {hash}").into(),
92 extra_headers: Vec::new(),
93 };
94 let commit_id = repo.write_object(&commit).map_err(read)?.detach();
95
96 use gix::refs::{
97 Target,
98 transaction::{
99 Change,
100 LogChange,
101 PreviousValue,
102 RefEdit,
103 RefLog,
104 },
105 };
106 let name: gix::refs::FullName = REF
107 .try_into()
108 .map_err(|e: gix::validate::reference::name::Error| read(e))?;
109 repo.edit_reference(RefEdit {
110 change: Change::Update {
111 log: LogChange {
112 mode: RefLog::AndReference,
113 force_create_reflog: false,
114 message: "mirror attachment".into(),
115 },
116 expected: PreviousValue::Any,
117 new: Target::Object(commit_id),
118 },
119 name,
120 deref: false,
121 })
122 .map_err(read)?;
123 Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 fn git(dir: &Path, args: &[&str]) -> std::process::Output {
131 std::process::Command::new("git")
132 .args(args)
133 .current_dir(dir)
134 .env("GIT_AUTHOR_NAME", "t")
135 .env("GIT_AUTHOR_EMAIL", "t@example.com")
136 .env("GIT_COMMITTER_NAME", "t")
137 .env("GIT_COMMITTER_EMAIL", "t@example.com")
138 .output()
139 .expect("run git")
140 }
141
142 #[test]
143 fn mirrors_blobs_addressable_by_hash() {
144 let tmp = tempfile::tempdir().unwrap();
145 let dir = tmp.path();
146 assert!(git(dir, &["init", "-q", "-b", "main"]).status.success());
147 std::fs::write(dir.join("f"), "seed").unwrap();
148 git(dir, &["add", "."]);
149 git(dir, &["commit", "-qm", "seed"]);
150
151 add(dir, "aaaa", b"first bytes").unwrap();
152 add(dir, "bbbb", b"second bytes").unwrap();
153 // Re-adding identical content is a no-op; new content updates in place.
154 add(dir, "aaaa", b"first bytes").unwrap();
155 add(dir, "aaaa", b"first bytes v2").unwrap();
156
157 // Each blob is retrievable by its hash via the side ref.
158 let out = git(dir, &["cat-file", "-p", &format!("{REF}:bbbb")]);
159 assert!(out.status.success());
160 assert_eq!(out.stdout, b"second bytes");
161 let out = git(dir, &["cat-file", "-p", &format!("{REF}:aaaa")]);
162 assert_eq!(out.stdout, b"first bytes v2");
163
164 // The ref is off the branch namespace — main still has just its commit.
165 let log = git(dir, &["log", "--oneline", "main"]);
166 assert_eq!(String::from_utf8_lossy(&log.stdout).lines().count(), 1);
167
168 // fsck stays clean after our hand-built objects.
169 assert!(git(dir, &["fsck", "--strict"]).status.success());
170 }
171}