Skip to main content

torii_lib/vcs/
sign.rs

1//! GPG signing over existing commit objects — data layer for `torii sign`
2//! and `torii show --signature`. Presentation (prompts, printing) lives in
3//! `cli/sign.rs`; everything here returns data and precise errors.
4
5use crate::core::GitRepo;
6use crate::error::{Result, ToriiError};
7
8/// Armor + signed payload extracted from a commit's `gpgsig` header.
9pub struct CommitSignature {
10    pub oid: String,
11    pub armor: String,
12    pub payload: Vec<u8>,
13}
14
15/// One rewritten commit from a signing pass.
16pub struct SignedRewrite {
17    pub old: String,
18    pub new: String,
19}
20
21/// Result of [`GitRepo::sign_range`].
22pub struct SignOutcome {
23    pub rewritten: Vec<SignedRewrite>,
24    pub branches_moved: usize,
25}
26
27impl GitRepo {
28    /// Extract the GPG armor and signed payload from a commit. Errors with
29    /// [`ToriiError::RepoState`] when the commit carries no signature.
30    pub fn extract_commit_signature(&self, target: &str) -> Result<CommitSignature> {
31        let r = &self.repo;
32        let oid = r
33            .revparse_single(target)
34            .map_err(|e| ToriiError::Usage(format!("`{}`: {}", target, e)))?
35            .id();
36
37        let (sig_buf, payload_buf) = r.extract_signature(&oid, None).map_err(|_| {
38            ToriiError::RepoState(format!(
39                "commit {} has no GPG signature attached. Use `torii sign {}` to add one.",
40                &oid.to_string()[..8],
41                target
42            ))
43        })?;
44        let armor = std::str::from_utf8(&sig_buf)
45            .map_err(|e| ToriiError::RepoState(format!("signature is not valid UTF-8: {}", e)))?
46            .to_string();
47
48        Ok(CommitSignature {
49            oid: oid.to_string(),
50            armor,
51            payload: (&*payload_buf).to_vec(),
52        })
53    }
54
55    /// Resolve a `<rev>` or `<from>..<to>` spec to the commit OIDs it
56    /// covers (newest first, revwalk order). Public so callers can show
57    /// counts / ask for confirmation before a destructive pass.
58    pub fn resolve_commit_range(&self, target: &str) -> Result<Vec<String>> {
59        Ok(self
60            .resolve_range_oids(target)?
61            .iter()
62            .map(|o| o.to_string())
63            .collect())
64    }
65
66    fn resolve_range_oids(&self, target: &str) -> Result<Vec<git2::Oid>> {
67        let r = &self.repo;
68        if let Some((from, to)) = target.split_once("..") {
69            let from_oid = r.revparse_single(from)?.id();
70            let to_oid = r.revparse_single(to)?.id();
71            let mut walk = r.revwalk()?;
72            walk.push(to_oid)?;
73            walk.hide(from_oid)?;
74            Ok(walk.flatten().collect())
75        } else {
76            Ok(vec![r.revparse_single(target)?.id()])
77        }
78    }
79
80    /// Render the signature armor each commit in the range *would* get,
81    /// without rewriting anything. Returns `(oid, armor)` pairs.
82    pub fn preview_signatures(
83        &self,
84        target: &str,
85        key: &str,
86        gpg_program: Option<&str>,
87    ) -> Result<Vec<(String, String)>> {
88        let r = &self.repo;
89        let mut out = Vec::new();
90        for oid in self.resolve_range_oids(target)? {
91            let commit = r.find_commit(oid)?;
92            let buffer = r.commit_create_buffer(
93                &commit.author(),
94                &commit.committer(),
95                commit.message().unwrap_or(""),
96                &commit.tree()?,
97                &commit
98                    .parents()
99                    .collect::<Vec<_>>()
100                    .iter()
101                    .collect::<Vec<_>>(),
102            )?;
103            let armor = crate::gpg::sign_blob(&buffer, key, gpg_program)?;
104            out.push((oid.to_string(), armor));
105        }
106        Ok(out)
107    }
108
109    /// Rewrite every commit in the range with a fresh `gpgsig` header and
110    /// move affected local branch tips onto the rewritten OIDs. Refuses to
111    /// run on a dirty work tree — rewriting history with uncommitted
112    /// changes makes the resulting state hard to reason about.
113    pub fn sign_range(
114        &self,
115        target: &str,
116        key: &str,
117        gpg_program: Option<&str>,
118    ) -> Result<SignOutcome> {
119        let r = &self.repo;
120
121        let mut opts = git2::StatusOptions::new();
122        opts.include_untracked(false);
123        if !r.statuses(Some(&mut opts))?.is_empty() {
124            return Err(ToriiError::RepoState(
125                "working tree is dirty — commit or stash first. (`torii sign` rewrites \
126                 history; running with uncommitted changes makes the resulting state \
127                 hard to reason about.)"
128                    .to_string(),
129            ));
130        }
131
132        let oids = self.resolve_range_oids(target)?;
133
134        // Walk the range oldest-first so each rewrite's child can reuse
135        // the parent's new OID instead of the original.
136        let mut ordered = oids.clone();
137        ordered.reverse();
138        let mut remap: std::collections::HashMap<git2::Oid, git2::Oid> =
139            std::collections::HashMap::new();
140        let mut rewritten = Vec::new();
141
142        for oid in &ordered {
143            let commit = r.find_commit(*oid)?;
144            let parents: Vec<git2::Commit> = commit
145                .parents()
146                .map(|p| {
147                    let real = remap.get(&p.id()).copied().unwrap_or(p.id());
148                    r.find_commit(real).map_err(ToriiError::Git)
149                })
150                .collect::<Result<Vec<_>>>()?;
151            let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
152            let tree = commit.tree()?;
153            let buffer = r.commit_create_buffer(
154                &commit.author(),
155                &commit.committer(),
156                commit.message().unwrap_or(""),
157                &tree,
158                &parent_refs,
159            )?;
160            let buffer_str = std::str::from_utf8(&buffer)
161                .map_err(|e| ToriiError::RepoState(format!("commit buffer is not UTF-8: {}", e)))?;
162            let armor = crate::gpg::sign_blob(&buffer, key, gpg_program)?;
163            let new_oid = r.commit_signed(buffer_str, &armor, Some("gpgsig"))?;
164            remap.insert(*oid, new_oid);
165            rewritten.push(SignedRewrite {
166                old: oid.to_string(),
167                new: new_oid.to_string(),
168            });
169        }
170
171        // Move every local branch whose tip is one of the rewritten oids
172        // onto the new oid.
173        let mut moved = 0usize;
174        for b in r.branches(Some(git2::BranchType::Local))?.flatten() {
175            let (br, _) = b;
176            let tip = br.get().target();
177            if let (Some(t), Some(name)) = (tip, br.name().ok().flatten()) {
178                if let Some(new_oid) = remap.get(&t) {
179                    r.reference(
180                        &format!("refs/heads/{}", name),
181                        *new_oid,
182                        true,
183                        "torii sign — re-sign history",
184                    )?;
185                    moved += 1;
186                }
187            }
188        }
189
190        Ok(SignOutcome {
191            rewritten,
192            branches_moved: moved,
193        })
194    }
195}