Skip to main content

solid_pod_rs_git/
mark.rs

1//! Native `GitMarker` implementation — the cheap, always-on provenance tier.
2//!
3//! Implements [`solid_pod_rs::provenance::GitMarker`] by shelling out to the
4//! system `git` binary via [`tokio::process::Command`], mirroring the style of
5//! [`crate::api`] (`git_commit`/`git_add`) and [`crate::service`]. This is the
6//! single canonical write-provenance path: an LDP `PUT`/`POST`/`PATCH` that
7//! lands in a git-backed pod is followed by a [`ShellGitMarker::mark_write`]
8//! call that stages the written file and commits it, so every pod write becomes
9//! a git commit whose SHA is captured and surfaced as a
10//! [`GitMark`].
11//!
12//! ## Why native-only
13//!
14//! The pure provenance *surface* (types + `prov_ttl`) lives in
15//! `solid-pod-rs::provenance` and compiles for `wasm32`. This implementor
16//! spawns a subprocess, so it is native-only and lives here in
17//! `solid-pod-rs-git` (which already depends on `tokio::process`). Wasm
18//! consumers compile against a no-op marker, never this one
19//! (ADR-059 D4).
20//!
21//! ## Commit identity
22//!
23//! Commits are authored as `solid-pod-rs <agent_did>` — the writer's
24//! `did:nostr` (NIP-98 principal) becomes the commit author *email*, binding
25//! the on-disk git history to the authenticated agent. Identity is injected
26//! per-invocation via `git -c user.name=… -c user.email=…` so no global git
27//! config is mutated and the repo needs no pre-seeded `user.*`.
28
29use std::path::Path;
30
31use async_trait::async_trait;
32use solid_pod_rs::provenance::{GitMark, GitMarker, ProvenanceError};
33use tokio::process::Command;
34
35/// Branch the auto-init pins (`init.rs` runs `git init -b main`). Every mark
36/// records this as its branch; surfacing it keeps the [`GitMark`] self-describing
37/// without a second `rev-parse --abbrev-ref` round-trip per write.
38const PINNED_BRANCH: &str = "main";
39
40/// A [`GitMarker`] that commits pod writes by shelling to `git`.
41///
42/// Stateless and cheap to clone — it holds only the committer name used in the
43/// `git -c user.name=…` override.
44#[derive(Debug, Clone)]
45pub struct ShellGitMarker {
46    /// Value passed to `git -c user.name=…`. Defaults to `solid-pod-rs`.
47    committer_name: String,
48}
49
50impl ShellGitMarker {
51    /// Construct with the default committer name (`solid-pod-rs`).
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            committer_name: "solid-pod-rs".to_string(),
56        }
57    }
58
59    /// Override the committer name written into `git -c user.name=…`.
60    #[must_use]
61    pub fn with_committer_name(name: impl Into<String>) -> Self {
62        Self {
63            committer_name: name.into(),
64        }
65    }
66}
67
68impl Default for ShellGitMarker {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74/// Run a git command in `repo`, returning trimmed stdout on success.
75///
76/// Maps a missing `git` binary and any non-zero exit into
77/// [`ProvenanceError::Git`] so the server hook can log-and-swallow uniformly.
78async fn git(repo: &Path, args: &[&str]) -> Result<String, ProvenanceError> {
79    // The repo lives in the pod's data tree, so its own `.git/config` is not
80    // trusted: no system/global config, and the repo-local settings that run
81    // programs (fsmonitor, hooks, ssh) are overridden on every invocation.
82    let output = Command::new("git")
83        .args([
84            "-c",
85            "core.fsmonitor=false",
86            "-c",
87            "core.hooksPath=/dev/null",
88            "-c",
89            "core.sshCommand=false",
90            "-c",
91            "commit.gpgSign=false",
92        ])
93        .args(args)
94        .current_dir(repo)
95        .env("GIT_CONFIG_NOSYSTEM", "1")
96        .env("GIT_CONFIG_GLOBAL", "/dev/null")
97        .output()
98        .await
99        .map_err(|e| {
100            if e.kind() == std::io::ErrorKind::NotFound {
101                ProvenanceError::Git("git binary not found in PATH".into())
102            } else {
103                ProvenanceError::Git(format!("spawn git {args:?}: {e}"))
104            }
105        })?;
106
107    if output.status.success() {
108        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
109    } else {
110        // `git commit` writes its human status ("nothing to commit, working
111        // tree clean") to STDOUT, not stderr, yet still exits non-zero. Fold
112        // both streams into the error message so the no-op detection in
113        // `mark_write` can match on it.
114        let stderr = String::from_utf8_lossy(&output.stderr);
115        let stdout = String::from_utf8_lossy(&output.stdout);
116        let detail = match (stderr.trim().is_empty(), stdout.trim().is_empty()) {
117            (false, false) => format!("{}; {}", stderr.trim(), stdout.trim()),
118            (false, true) => stderr.trim().to_string(),
119            (true, false) => stdout.trim().to_string(),
120            (true, true) => String::new(),
121        };
122        Err(ProvenanceError::Git(format!(
123            "git {args:?} exited {:?}: {detail}",
124            output.status.code(),
125        )))
126    }
127}
128
129/// Derive the pod repo slug from the repo directory's final path component
130/// (the pod name / pubkey segment), matching the server's
131/// `data_root/{pod}` layout. Falls back to the full lossy path when the
132/// component cannot be extracted (e.g. a root path).
133fn repo_slug(repo: &Path) -> String {
134    repo.file_name()
135        .map(|s| s.to_string_lossy().into_owned())
136        .unwrap_or_else(|| repo.to_string_lossy().into_owned())
137}
138
139/// Resolve `HEAD` to a SHA, mapping the unborn-branch case (a fresh
140/// `git init` with no commits) to `Ok(None)` rather than an error.
141async fn head_sha(repo: &Path) -> Result<Option<String>, ProvenanceError> {
142    match git(repo, &["rev-parse", "HEAD"]).await {
143        Ok(sha) if !sha.is_empty() => Ok(Some(sha)),
144        Ok(_) => Ok(None),
145        // `rev-parse HEAD` fails on an unborn branch (`fatal: ambiguous
146        // argument 'HEAD'` / `unknown revision`). That is "no commits yet",
147        // not a hard failure.
148        Err(ProvenanceError::Git(msg))
149            if msg.contains("unknown revision")
150                || msg.contains("ambiguous argument")
151                || msg.contains("bad revision")
152                || msg.contains("does not have any commits") =>
153        {
154            Ok(None)
155        }
156        Err(e) => Err(e),
157    }
158}
159
160#[async_trait(?Send)]
161impl GitMarker for ShellGitMarker {
162    async fn mark_write(
163        &self,
164        repo: &Path,
165        path: &str,
166        agent_did: &str,
167        message: &str,
168    ) -> Result<GitMark, ProvenanceError> {
169        // Reject path escapes defensively — the server already constrains the
170        // path, but a marker must never `git add` outside the repo.
171        if path.starts_with('/') || path.contains("..") {
172            return Err(ProvenanceError::InvalidPath(path.to_string()));
173        }
174
175        // 1. Record the pre-write HEAD — this is the parent of whatever commit
176        //    we are about to create (the append-only chain link).
177        let parent = head_sha(repo).await?;
178
179        // 2. Stage the written file. `git add` materialises the index entry the
180        //    commit will snapshot.
181        git(repo, &["add", "--", path]).await?;
182
183        // 3. Commit with the agent's did:nostr as the author email. Identity is
184        //    injected per-invocation via `-c` overrides so no global git config
185        //    is touched and the repo needs no pre-seeded `user.*`.
186        //    `--allow-empty` is deliberately NOT passed: an idempotent re-write
187        //    of identical bytes yields "nothing to commit", which we treat as a
188        //    no-op below rather than fabricating an empty commit.
189        let name_cfg = format!("user.name={}", self.committer_name);
190        let email_cfg = format!("user.email={agent_did}");
191        let commit_res = git(
192            repo,
193            &["-c", &name_cfg, "-c", &email_cfg, "commit", "-m", message],
194        )
195        .await;
196
197        // 4. Resolve the resulting HEAD. On success it is the new commit; on
198        //    "nothing to commit" it is unchanged from `parent`.
199        match commit_res {
200            Ok(_) => {
201                let commit_sha = head_sha(repo)
202                    .await?
203                    .ok_or_else(|| ProvenanceError::Git("HEAD unresolved after commit".into()))?;
204                Ok(GitMark {
205                    commit_sha,
206                    repo: repo_slug(repo),
207                    branch: PINNED_BRANCH.to_string(),
208                    parent,
209                })
210            }
211            Err(ProvenanceError::Git(msg))
212                if msg.contains("nothing to commit")
213                    || msg.contains("no changes added")
214                    || msg.contains("working tree clean")
215                    || msg.contains("nothing added to commit") =>
216            {
217                // Idempotent re-write: nothing changed. Surface the current
218                // HEAD as the mark without erroring (the resource is already at
219                // this commit). Its "parent" is HEAD's own parent.
220                match &parent {
221                    Some(head) => {
222                        let head_parent = git(repo, &["rev-parse", &format!("{head}^")])
223                            .await
224                            .ok()
225                            .filter(|s| !s.is_empty());
226                        Ok(GitMark {
227                            commit_sha: head.clone(),
228                            repo: repo_slug(repo),
229                            branch: PINNED_BRANCH.to_string(),
230                            parent: head_parent,
231                        })
232                    }
233                    // No prior commit AND nothing to commit: an empty add of an
234                    // already-clean tree. Nothing to surface — propagate as a
235                    // soft git error the caller swallows.
236                    None => Err(ProvenanceError::Git(
237                        "nothing to commit and no prior HEAD".into(),
238                    )),
239                }
240            }
241            Err(e) => Err(e),
242        }
243    }
244
245    async fn head(&self, repo: &Path) -> Result<Option<String>, ProvenanceError> {
246        head_sha(repo).await
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Tests
252// ---------------------------------------------------------------------------
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use std::process::Stdio;
258    use tempfile::TempDir;
259
260    fn git_available() -> bool {
261        std::process::Command::new("git")
262            .arg("--version")
263            .stdout(Stdio::null())
264            .stderr(Stdio::null())
265            .status()
266            .map(|s| s.success())
267            .unwrap_or(false)
268    }
269
270    /// Initialise a non-bare repo on branch `main` with a committer identity.
271    async fn init_repo() -> TempDir {
272        let td = TempDir::new().unwrap();
273        let run = |args: &[&str]| {
274            std::process::Command::new("git")
275                .args(args)
276                .current_dir(td.path())
277                .stdout(Stdio::null())
278                .stderr(Stdio::null())
279                .status()
280                .unwrap();
281        };
282        run(&["init", "-b", "main"]);
283        td
284    }
285
286    async fn write_file(repo: &Path, rel: &str, contents: &str) {
287        let abs = repo.join(rel);
288        if let Some(parent) = abs.parent() {
289            tokio::fs::create_dir_all(parent).await.unwrap();
290        }
291        tokio::fs::write(abs, contents).await.unwrap();
292    }
293
294    #[cfg(unix)]
295    #[tokio::test]
296    async fn repo_local_hooks_do_not_run() {
297        use std::os::unix::fs::PermissionsExt;
298        if !git_available() {
299            return;
300        }
301        let td = init_repo().await;
302        let ran = td.path().join("hook-ran");
303        let hook = td.path().join(".git/hooks/pre-commit");
304        write_file(
305            td.path(),
306            ".git/hooks/pre-commit",
307            &format!("#!/bin/sh\ntouch '{}'\n", ran.display()),
308        )
309        .await;
310        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
311        write_file(td.path(), "a.txt", "a").await;
312        ShellGitMarker::new()
313            .mark_write(td.path(), "a.txt", "did:nostr:abcd", "m")
314            .await
315            .unwrap();
316        assert!(!ran.exists(), "a repo-local hook ran");
317    }
318
319    #[cfg(unix)]
320    #[tokio::test]
321    async fn repo_local_gpg_program_does_not_run() {
322        use std::os::unix::fs::PermissionsExt;
323        if !git_available() {
324            return;
325        }
326        let td = init_repo().await;
327        let ran = td.path().join("gpg-ran");
328        let prog = td.path().join("fake-gpg");
329        write_file(
330            td.path(),
331            "fake-gpg",
332            &format!("#!/bin/sh\ntouch '{}'\nexit 1\n", ran.display()),
333        )
334        .await;
335        std::fs::set_permissions(&prog, std::fs::Permissions::from_mode(0o755)).unwrap();
336        let cfg = td.path().join(".git/config");
337        let mut conf = std::fs::read_to_string(&cfg).unwrap();
338        conf.push_str(&format!(
339            "[commit]\n\tgpgsign = true\n[gpg]\n\tprogram = {}\n",
340            prog.display()
341        ));
342        std::fs::write(&cfg, conf).unwrap();
343        write_file(td.path(), "a.txt", "a").await;
344        ShellGitMarker::new()
345            .mark_write(td.path(), "a.txt", "did:nostr:abcd", "m")
346            .await
347            .unwrap();
348        assert!(!ran.exists(), "a repo-local gpg program ran");
349    }
350
351    #[tokio::test]
352    async fn head_is_none_on_unborn_branch() {
353        if !git_available() {
354            return;
355        }
356        let td = init_repo().await;
357        let marker = ShellGitMarker::new();
358        assert_eq!(marker.head(td.path()).await.unwrap(), None);
359    }
360
361    #[tokio::test]
362    async fn mark_write_creates_commit_and_captures_sha() {
363        if !git_available() {
364            return;
365        }
366        let td = init_repo().await;
367        let marker = ShellGitMarker::new();
368
369        write_file(td.path(), "notes/hello.ttl", "<a> <b> <c> .").await;
370        let mark = marker
371            .mark_write(
372                td.path(),
373                "notes/hello.ttl",
374                "did:nostr:abcd",
375                "PUT /notes/hello.ttl",
376            )
377            .await
378            .unwrap();
379
380        // SHA is a 40-hex commit id and equals the new HEAD.
381        assert_eq!(mark.commit_sha.len(), 40);
382        assert!(mark.commit_sha.bytes().all(|b| b.is_ascii_hexdigit()));
383        assert_eq!(mark.branch, "main");
384        assert_eq!(mark.repo, td.path().file_name().unwrap().to_string_lossy());
385        // First commit of a fresh repo has no parent.
386        assert_eq!(mark.parent, None);
387
388        let head = marker.head(td.path()).await.unwrap().unwrap();
389        assert_eq!(head, mark.commit_sha);
390
391        // The agent did:nostr is the author email on the commit.
392        let email = git(td.path(), &["log", "-1", "--format=%ae"])
393            .await
394            .unwrap();
395        assert_eq!(email, "did:nostr:abcd");
396        let name = git(td.path(), &["log", "-1", "--format=%an"])
397            .await
398            .unwrap();
399        assert_eq!(name, "solid-pod-rs");
400    }
401
402    #[tokio::test]
403    async fn parent_chain_links_two_writes() {
404        if !git_available() {
405            return;
406        }
407        let td = init_repo().await;
408        let marker = ShellGitMarker::new();
409
410        write_file(td.path(), "a.ttl", "first").await;
411        let m1 = marker
412            .mark_write(td.path(), "a.ttl", "did:nostr:a", "write a")
413            .await
414            .unwrap();
415
416        write_file(td.path(), "b.ttl", "second").await;
417        let m2 = marker
418            .mark_write(td.path(), "b.ttl", "did:nostr:b", "write b")
419            .await
420            .unwrap();
421
422        // The second mark's parent is the first mark's commit — the append-only
423        // chain. SHAs differ.
424        assert_ne!(m1.commit_sha, m2.commit_sha);
425        assert_eq!(m1.parent, None);
426        assert_eq!(m2.parent.as_deref(), Some(m1.commit_sha.as_str()));
427    }
428
429    #[tokio::test]
430    async fn nothing_to_commit_returns_head_without_error() {
431        if !git_available() {
432            return;
433        }
434        let td = init_repo().await;
435        let marker = ShellGitMarker::new();
436
437        write_file(td.path(), "a.ttl", "content").await;
438        let m1 = marker
439            .mark_write(td.path(), "a.ttl", "did:nostr:a", "write a")
440            .await
441            .unwrap();
442
443        // Re-mark the SAME path with identical content already committed: there
444        // is nothing to commit. We must get a mark referencing the current HEAD,
445        // not an error, and HEAD must not have advanced.
446        let m2 = marker
447            .mark_write(td.path(), "a.ttl", "did:nostr:a", "re-write a")
448            .await
449            .unwrap();
450        assert_eq!(m2.commit_sha, m1.commit_sha, "HEAD must not advance");
451        assert_eq!(
452            marker.head(td.path()).await.unwrap().as_deref(),
453            Some(m1.commit_sha.as_str())
454        );
455    }
456
457    #[tokio::test]
458    async fn rejects_path_traversal() {
459        if !git_available() {
460            return;
461        }
462        let td = init_repo().await;
463        let marker = ShellGitMarker::new();
464        assert!(matches!(
465            marker
466                .mark_write(td.path(), "../escape.ttl", "did:nostr:a", "x")
467                .await,
468            Err(ProvenanceError::InvalidPath(_))
469        ));
470        assert!(matches!(
471            marker
472                .mark_write(td.path(), "/abs.ttl", "did:nostr:a", "x")
473                .await,
474            Err(ProvenanceError::InvalidPath(_))
475        ));
476    }
477}