anvilsign in

collin/anvil

1//! Uploaded attachments: content-addressed files stored outside git.
2//!
3//! The bytes live on disk under `storage::attachment_path` (never in a git
4//! repository); this module owns the database rows that index them per repo
5//! and the content hashing used as their address. See [`crate::models::Attachment`].
6
7use sha2::{
8 Digest,
9 Sha256,
10};
11
12use crate::{
13 error::Result,
14 models::Attachment,
15};
16
17/// Lowercase hex SHA-256 of `bytes` — the content address used as both the
18/// dedup key and the on-disk filename.
19pub fn content_hash(bytes: &[u8]) -> String {
20 Sha256::digest(bytes)
21 .iter()
22 .map(|b| format!("{b:02x}"))
23 .collect()
24}
25
26/// The attachment row for `(repo_id, hash)`, if one exists.
27pub async fn find(db: &toasty::Db, repo_id: i64, hash: &str) -> Result<Option<Attachment>> {
28 let mut conn = db.clone();
29 let row = Attachment::filter(Attachment::fields().repo_id().eq(repo_id))
30 .filter(Attachment::fields().hash().eq(hash))
31 .first()
32 .exec(&mut conn)
33 .await?;
34 Ok(row)
35}
36
37/// Delete every attachment row for a repository. The bytes under
38/// [`crate::storage::attachment_path`] are the caller's to remove — see
39/// [`repos::delete`](crate::repos::delete).
40pub async fn delete_for_repo(db: &toasty::Db, repo_id: i64) -> Result<()> {
41 let mut conn = db.clone();
42 let rows = Attachment::filter(Attachment::fields().repo_id().eq(repo_id))
43 .exec(&mut conn)
44 .await?;
45 for row in rows {
46 let mut conn = db.clone();
47 row.delete().exec(&mut conn).await?;
48 }
49 Ok(())
50}
51
52/// Record an attachment for a repo, deduping on content: if `hash` is already
53/// recorded for `repo_id` the existing row is returned and no new row is made.
54/// The caller writes the bytes to [`crate::storage::attachment_path`] itself.
55pub async fn add(
56 db: &toasty::Db,
57 repo_id: i64,
58 hash: &str,
59 content_type: &str,
60 size: i64,
61 uploader_id: i64,
62) -> Result<Attachment> {
63 if let Some(existing) = find(db, repo_id, hash).await? {
64 return Ok(existing);
65 }
66 let mut conn = db.clone();
67 let row = toasty::create!(Attachment {
68 repo_id: repo_id,
69 hash: hash,
70 content_type: content_type,
71 size: size,
72 uploader_id: uploader_id,
73 created_at: crate::now(),
74 })
75 .exec(&mut conn)
76 .await?;
77 Ok(row)
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 #[test]
85 fn content_hash_is_stable_lowercase_hex_sha256() {
86 // Known SHA-256 of "hello".
87 assert_eq!(
88 content_hash(b"hello"),
89 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
90 );
91 assert_eq!(content_hash(b"a"), content_hash(b"a"));
92 assert_ne!(content_hash(b"a"), content_hash(b"b"));
93 }
94}