| 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 | |
| 13 | use std::path::Path; |
| 14 | |
| 15 | use gix::objs::tree; |
| 16 | |
| 17 | use 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. |
| 24 | pub const REF: &str = "refs/anvil/attachments"; |
| 25 | |
| 26 | fn 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). |
| 34 | pub 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)] |
| 127 | mod 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 | } |