1use std::path::Path;
30
31use async_trait::async_trait;
32use solid_pod_rs::provenance::{GitMark, GitMarker, ProvenanceError};
33use tokio::process::Command;
34
35const PINNED_BRANCH: &str = "main";
39
40#[derive(Debug, Clone)]
45pub struct ShellGitMarker {
46 committer_name: String,
48}
49
50impl ShellGitMarker {
51 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 committer_name: "solid-pod-rs".to_string(),
56 }
57 }
58
59 #[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
74async fn git(repo: &Path, args: &[&str]) -> Result<String, ProvenanceError> {
79 let output = Command::new("git")
80 .args(args)
81 .current_dir(repo)
82 .output()
83 .await
84 .map_err(|e| {
85 if e.kind() == std::io::ErrorKind::NotFound {
86 ProvenanceError::Git("git binary not found in PATH".into())
87 } else {
88 ProvenanceError::Git(format!("spawn git {args:?}: {e}"))
89 }
90 })?;
91
92 if output.status.success() {
93 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
94 } else {
95 let stderr = String::from_utf8_lossy(&output.stderr);
100 let stdout = String::from_utf8_lossy(&output.stdout);
101 let detail = match (stderr.trim().is_empty(), stdout.trim().is_empty()) {
102 (false, false) => format!("{}; {}", stderr.trim(), stdout.trim()),
103 (false, true) => stderr.trim().to_string(),
104 (true, false) => stdout.trim().to_string(),
105 (true, true) => String::new(),
106 };
107 Err(ProvenanceError::Git(format!(
108 "git {args:?} exited {:?}: {detail}",
109 output.status.code(),
110 )))
111 }
112}
113
114fn repo_slug(repo: &Path) -> String {
119 repo.file_name()
120 .map(|s| s.to_string_lossy().into_owned())
121 .unwrap_or_else(|| repo.to_string_lossy().into_owned())
122}
123
124async fn head_sha(repo: &Path) -> Result<Option<String>, ProvenanceError> {
127 match git(repo, &["rev-parse", "HEAD"]).await {
128 Ok(sha) if !sha.is_empty() => Ok(Some(sha)),
129 Ok(_) => Ok(None),
130 Err(ProvenanceError::Git(msg))
134 if msg.contains("unknown revision")
135 || msg.contains("ambiguous argument")
136 || msg.contains("bad revision")
137 || msg.contains("does not have any commits") =>
138 {
139 Ok(None)
140 }
141 Err(e) => Err(e),
142 }
143}
144
145#[async_trait(?Send)]
146impl GitMarker for ShellGitMarker {
147 async fn mark_write(
148 &self,
149 repo: &Path,
150 path: &str,
151 agent_did: &str,
152 message: &str,
153 ) -> Result<GitMark, ProvenanceError> {
154 if path.starts_with('/') || path.contains("..") {
157 return Err(ProvenanceError::InvalidPath(path.to_string()));
158 }
159
160 let parent = head_sha(repo).await?;
163
164 git(repo, &["add", "--", path]).await?;
167
168 let name_cfg = format!("user.name={}", self.committer_name);
175 let email_cfg = format!("user.email={agent_did}");
176 let commit_res = git(
177 repo,
178 &["-c", &name_cfg, "-c", &email_cfg, "commit", "-m", message],
179 )
180 .await;
181
182 match commit_res {
185 Ok(_) => {
186 let commit_sha = head_sha(repo)
187 .await?
188 .ok_or_else(|| ProvenanceError::Git("HEAD unresolved after commit".into()))?;
189 Ok(GitMark {
190 commit_sha,
191 repo: repo_slug(repo),
192 branch: PINNED_BRANCH.to_string(),
193 parent,
194 })
195 }
196 Err(ProvenanceError::Git(msg))
197 if msg.contains("nothing to commit")
198 || msg.contains("no changes added")
199 || msg.contains("working tree clean")
200 || msg.contains("nothing added to commit") =>
201 {
202 match &parent {
206 Some(head) => {
207 let head_parent = git(repo, &["rev-parse", &format!("{head}^")])
208 .await
209 .ok()
210 .filter(|s| !s.is_empty());
211 Ok(GitMark {
212 commit_sha: head.clone(),
213 repo: repo_slug(repo),
214 branch: PINNED_BRANCH.to_string(),
215 parent: head_parent,
216 })
217 }
218 None => Err(ProvenanceError::Git(
222 "nothing to commit and no prior HEAD".into(),
223 )),
224 }
225 }
226 Err(e) => Err(e),
227 }
228 }
229
230 async fn head(&self, repo: &Path) -> Result<Option<String>, ProvenanceError> {
231 head_sha(repo).await
232 }
233}
234
235#[cfg(test)]
240mod tests {
241 use super::*;
242 use std::process::Stdio;
243 use tempfile::TempDir;
244
245 fn git_available() -> bool {
246 std::process::Command::new("git")
247 .arg("--version")
248 .stdout(Stdio::null())
249 .stderr(Stdio::null())
250 .status()
251 .map(|s| s.success())
252 .unwrap_or(false)
253 }
254
255 async fn init_repo() -> TempDir {
257 let td = TempDir::new().unwrap();
258 let run = |args: &[&str]| {
259 std::process::Command::new("git")
260 .args(args)
261 .current_dir(td.path())
262 .stdout(Stdio::null())
263 .stderr(Stdio::null())
264 .status()
265 .unwrap();
266 };
267 run(&["init", "-b", "main"]);
268 td
269 }
270
271 async fn write_file(repo: &Path, rel: &str, contents: &str) {
272 let abs = repo.join(rel);
273 if let Some(parent) = abs.parent() {
274 tokio::fs::create_dir_all(parent).await.unwrap();
275 }
276 tokio::fs::write(abs, contents).await.unwrap();
277 }
278
279 #[tokio::test]
280 async fn head_is_none_on_unborn_branch() {
281 if !git_available() {
282 return;
283 }
284 let td = init_repo().await;
285 let marker = ShellGitMarker::new();
286 assert_eq!(marker.head(td.path()).await.unwrap(), None);
287 }
288
289 #[tokio::test]
290 async fn mark_write_creates_commit_and_captures_sha() {
291 if !git_available() {
292 return;
293 }
294 let td = init_repo().await;
295 let marker = ShellGitMarker::new();
296
297 write_file(td.path(), "notes/hello.ttl", "<a> <b> <c> .").await;
298 let mark = marker
299 .mark_write(
300 td.path(),
301 "notes/hello.ttl",
302 "did:nostr:abcd",
303 "PUT /notes/hello.ttl",
304 )
305 .await
306 .unwrap();
307
308 assert_eq!(mark.commit_sha.len(), 40);
310 assert!(mark.commit_sha.bytes().all(|b| b.is_ascii_hexdigit()));
311 assert_eq!(mark.branch, "main");
312 assert_eq!(mark.repo, td.path().file_name().unwrap().to_string_lossy());
313 assert_eq!(mark.parent, None);
315
316 let head = marker.head(td.path()).await.unwrap().unwrap();
317 assert_eq!(head, mark.commit_sha);
318
319 let email = git(td.path(), &["log", "-1", "--format=%ae"])
321 .await
322 .unwrap();
323 assert_eq!(email, "did:nostr:abcd");
324 let name = git(td.path(), &["log", "-1", "--format=%an"])
325 .await
326 .unwrap();
327 assert_eq!(name, "solid-pod-rs");
328 }
329
330 #[tokio::test]
331 async fn parent_chain_links_two_writes() {
332 if !git_available() {
333 return;
334 }
335 let td = init_repo().await;
336 let marker = ShellGitMarker::new();
337
338 write_file(td.path(), "a.ttl", "first").await;
339 let m1 = marker
340 .mark_write(td.path(), "a.ttl", "did:nostr:a", "write a")
341 .await
342 .unwrap();
343
344 write_file(td.path(), "b.ttl", "second").await;
345 let m2 = marker
346 .mark_write(td.path(), "b.ttl", "did:nostr:b", "write b")
347 .await
348 .unwrap();
349
350 assert_ne!(m1.commit_sha, m2.commit_sha);
353 assert_eq!(m1.parent, None);
354 assert_eq!(m2.parent.as_deref(), Some(m1.commit_sha.as_str()));
355 }
356
357 #[tokio::test]
358 async fn nothing_to_commit_returns_head_without_error() {
359 if !git_available() {
360 return;
361 }
362 let td = init_repo().await;
363 let marker = ShellGitMarker::new();
364
365 write_file(td.path(), "a.ttl", "content").await;
366 let m1 = marker
367 .mark_write(td.path(), "a.ttl", "did:nostr:a", "write a")
368 .await
369 .unwrap();
370
371 let m2 = marker
375 .mark_write(td.path(), "a.ttl", "did:nostr:a", "re-write a")
376 .await
377 .unwrap();
378 assert_eq!(m2.commit_sha, m1.commit_sha, "HEAD must not advance");
379 assert_eq!(
380 marker.head(td.path()).await.unwrap().as_deref(),
381 Some(m1.commit_sha.as_str())
382 );
383 }
384
385 #[tokio::test]
386 async fn rejects_path_traversal() {
387 if !git_available() {
388 return;
389 }
390 let td = init_repo().await;
391 let marker = ShellGitMarker::new();
392 assert!(matches!(
393 marker
394 .mark_write(td.path(), "../escape.ttl", "did:nostr:a", "x")
395 .await,
396 Err(ProvenanceError::InvalidPath(_))
397 ));
398 assert!(matches!(
399 marker
400 .mark_write(td.path(), "/abs.ttl", "did:nostr:a", "x")
401 .await,
402 Err(ProvenanceError::InvalidPath(_))
403 ));
404 }
405}