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