1use std::path::Path;
30
31use async_trait::async_trait;
32use solid_pod_rs::provenance::{GitMark, GitMarker, ProvenanceError};
33
34const PINNED_BRANCH: &str = "main";
38
39#[derive(Debug, Clone)]
44pub struct ShellGitMarker {
45 committer_name: String,
47}
48
49impl ShellGitMarker {
50 #[must_use]
52 pub fn new() -> Self {
53 Self {
54 committer_name: "solid-pod-rs".to_string(),
55 }
56 }
57
58 #[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
73async fn git(repo: &Path, args: &[&str]) -> Result<String, ProvenanceError> {
78 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 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
116fn 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
126async 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 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 if path.starts_with('/') || path.contains("..") {
159 return Err(ProvenanceError::InvalidPath(path.to_string()));
160 }
161
162 let parent = head_sha(repo).await?;
165
166 git(repo, &["add", "--", path]).await?;
169
170 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 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 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 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#[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 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 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 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 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 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 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}