1use serde::Serialize;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct CheckpointId(pub String);
13
14#[derive(Debug, Clone, Serialize)]
16pub struct Checkpoint {
17 pub id: String,
19 pub label: String,
20 pub created: String,
22}
23
24#[derive(Debug, Clone, Serialize)]
26pub struct RestoreReport {
27 pub restored_to: String,
28 pub files_changed: usize,
29}
30
31pub(crate) const READ_ONLY: &[&str] = &[
38 "ls", "cat", "head", "tail", "grep", "egrep", "fgrep", "pwd", "echo", "printf", "which",
39 "whoami", "id", "hostname", "uname", "date", "stat", "file", "wc", "cut", "ps", "df", "du",
40 "free", "uptime",
41];
42
43pub(crate) fn is_read_only(command: &str) -> bool {
45 if command.contains('>')
49 || command.contains("$(")
50 || command.contains('`')
51 || command.contains("<(")
52 {
53 return false;
54 }
55 #[allow(clippy::manual_pattern_char_comparison)]
59 for seg in command.split(|c| matches!(c, '|' | ';' | '&' | '\n' | '\r')) {
60 let seg = seg.trim();
61 if seg.is_empty() {
62 continue;
63 }
64 let prog = seg.split_whitespace().next().unwrap_or("");
65 let base = prog.rsplit('/').next().unwrap_or(prog);
66 if !READ_ONLY.contains(&base) {
67 return false;
68 }
69 }
70 true
71}
72
73const BUILD_IGNORES: &[&str] = &[
76 ".git",
77 "node_modules",
78 "target",
79 ".venv",
80 "__pycache__",
81 ".mypy_cache",
82 "dist",
83 "build",
84];
85
86const SECRET_IGNORES: &[&str] = &[
96 ".cache",
97 ".ssh",
98 ".gnupg",
99 ".aws",
100 ".netrc",
101 ".execkit",
102 ".env",
103 ".env.*",
104 "*.pem",
105 "*.key",
106 "id_rsa*",
107 "id_ed25519*",
108];
109
110pub(crate) fn shq(s: &str) -> String {
112 format!("'{}'", s.replace('\'', "'\\''"))
113}
114
115pub(crate) struct Checkpointer {
118 token: String,
119 pub auto: bool,
120 pub workspace: Option<String>, paths: Vec<String>, ignores: Vec<String>, pub root: Option<String>, pub initialized: bool,
125 pub git_unavailable: bool,
126 pub last: Option<String>, }
128
129impl Checkpointer {
130 pub fn new(token: &str, auto: bool, workspace: Option<String>, paths: Vec<String>) -> Self {
131 debug_assert!(
135 token.bytes().all(|b| b.is_ascii_hexdigit()),
136 "checkpoint token must be hex"
137 );
138 let paths = if paths.is_empty() {
139 vec![".".into()]
140 } else {
141 paths
142 };
143 Self {
144 token: token.to_string(),
145 auto,
146 workspace,
147 paths,
148 ignores: Vec::new(),
149 root: None,
150 initialized: false,
151 git_unavailable: false,
152 last: None,
153 }
154 }
155
156 fn git_dir(&self) -> String {
157 format!("\"$HOME/.execkit/ckpt-{}.git\"", self.token)
159 }
160
161 pub(crate) fn shadow_repo_name(&self) -> String {
164 format!("ckpt-{}.git", self.token)
165 }
166
167 fn pathspec(&self) -> String {
168 self.paths
169 .iter()
170 .map(|p| shq(p))
171 .collect::<Vec<_>>()
172 .join(" ")
173 }
174
175 fn git(&self, root: &str) -> String {
176 format!(
180 "git -C {root} --git-dir={gd} --work-tree={root}",
181 root = shq(root),
182 gd = self.git_dir()
183 )
184 }
185
186 pub fn init_cmd(&self, root: &str) -> String {
188 let mut all: Vec<&str> = BUILD_IGNORES.to_vec();
193 all.extend(self.ignores.iter().map(String::as_str));
194 all.extend_from_slice(SECRET_IGNORES);
195 let excludes = all.join("\n");
196 let g = self.git(root);
197 format!(
198 "mkdir -p \"$HOME/.execkit\" && {g} init -q && \
199 printf '%s\\n' {ex} > {gd}/info/exclude",
200 g = g,
201 ex = shq(&excludes),
202 gd = self.git_dir(),
203 )
204 }
205
206 pub fn snapshot_cmd(&self, root: &str, label: &str) -> String {
213 let g = self.git(root);
214 format!(
215 "{g} add -- {paths}; \
216 {g} -c user.email=execkit@local -c user.name=execkit \
217 commit -q --allow-empty -m {label} && {g} rev-parse HEAD",
218 paths = self.pathspec(),
219 label = shq(label),
220 )
221 }
222
223 pub fn restore_cmd(&self, root: &str, id: &str) -> String {
224 let g = self.git(root);
233 format!(
234 "{g} checkout --no-overlay {id} -- {paths} && {g} clean -fdq -- {paths}",
235 id = shq(id),
236 paths = self.pathspec(),
237 )
238 }
239
240 pub fn list_cmd(&self, root: &str) -> String {
241 format!("{} log --format='%H %ct %s'", self.git(root))
245 }
246
247 pub fn set_ignores(&mut self, ignores: Vec<String>) {
248 self.ignores = ignores;
249 }
250
251 pub fn set_paths(&mut self, paths: Vec<String>) {
252 self.paths = paths;
253 }
254
255 pub fn cleanup_cmd(&self) -> String {
259 format!("rm -rf {}", self.git_dir())
260 }
261
262 pub fn diff_count_cmd(&self, root: &str, id: &str) -> String {
264 format!(
265 "{} diff --name-only {} -- {} | wc -l",
266 self.git(root),
267 shq(id),
268 self.pathspec()
269 )
270 }
271}
272
273pub(crate) fn parse_sha(out: &str) -> Option<String> {
275 out.split_whitespace().next().map(|s| s.to_string())
276}
277
278pub(crate) fn parse_log(out: &str) -> Vec<Checkpoint> {
280 out.lines()
281 .filter_map(|line| {
282 let mut it = line.splitn(3, ' ');
283 let id = it.next()?.trim();
284 if id.len() != 40 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
286 return None;
287 }
288 let created = it.next().unwrap_or("").to_string();
289 let label = it.next().unwrap_or("").to_string();
290 Some(Checkpoint {
291 id: id.to_string(),
292 label,
293 created,
294 })
295 })
296 .collect()
297}
298
299#[cfg(test)]
300mod builder_tests {
301 use super::{shq, Checkpointer};
302
303 fn cp() -> Checkpointer {
304 Checkpointer::new("abc123", true, None, vec![".".into()])
305 }
306
307 #[test]
308 fn shell_quote_is_safe() {
309 assert_eq!(shq("/srv/app"), "'/srv/app'");
310 assert_eq!(shq("a b"), "'a b'");
311 assert_eq!(shq("it's"), "'it'\\''s'");
312 }
313
314 #[test]
315 fn snapshot_and_restore_commands_are_scoped() {
316 let c = cp();
317 let root = "/srv/app";
318 let snap = c.snapshot_cmd(root, "before");
319 assert!(snap.contains("--git-dir=\"$HOME/.execkit/ckpt-abc123.git\""));
320 assert!(snap.contains("--work-tree='/srv/app'"));
321 assert!(snap.contains("add -- '.'"));
322 assert!(snap.contains("commit -q --allow-empty"));
323
324 let restore = c.restore_cmd(root, "deadbeef");
325 assert!(restore.contains("checkout --no-overlay 'deadbeef' -- '.'"));
326 assert!(restore.contains("clean -fdq -- '.'"));
327 }
328
329 #[test]
330 fn cleanup_cmd_removes_shadow_repo() {
331 let c = cp();
332 assert_eq!(c.cleanup_cmd(), "rm -rf \"$HOME/.execkit/ckpt-abc123.git\"");
333 }
334
335 #[test]
336 fn init_cmd_excludes_env_and_key_files() {
337 let cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
338 let cmd = cp.init_cmd("/srv/app");
339 for pat in [".env", ".env.*", "*.pem", "*.key", "id_rsa*", "id_ed25519*"] {
340 assert!(cmd.contains(pat), "missing secret ignore pattern: {pat}");
341 }
342 }
343
344 #[test]
345 fn multi_path_scopes_each_path() {
346 let c = Checkpointer::new(
347 "deadbeef",
348 true,
349 Some("/srv/app".into()),
350 vec!["src".into(), "migrations".into()],
351 );
352 let snap = c.snapshot_cmd("/srv/app", "x");
353 assert!(snap.contains("add -- 'src' 'migrations'"));
354 }
355
356 #[test]
357 fn parse_commit_sha_takes_first_token() {
358 assert_eq!(super::parse_sha("a1b2c3d4\n"), Some("a1b2c3d4".to_string()));
359 assert_eq!(super::parse_sha(" \n"), None);
360 }
361
362 #[test]
363 fn parse_log_reads_records() {
364 let a = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
366 let b = "cafebabecafebabecafebabecafebabecafebabe";
367 let out = format!("{a} 1700000000 before refactor\n{b} 1699999999 init\n");
368 let list = super::parse_log(&out);
369 assert_eq!(list.len(), 2);
370 assert_eq!(list[0].id, a);
371 assert_eq!(list[0].created, "1700000000");
372 assert_eq!(list[0].label, "before refactor");
373 assert_eq!(list[1].id, b);
374 let noisy = format!("{a} 1700000000 x\n~~~ junk ~~~\n");
376 assert_eq!(super::parse_log(&noisy).len(), 1);
377 }
378
379 #[test]
380 fn init_cmd_excludes_defaults_plus_user_ignores() {
381 let mut cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
382 cp.set_ignores(vec!["*.log".into(), "secrets".into()]);
383 let cmd = cp.init_cmd("/srv/app");
384 assert!(cmd.contains("node_modules"));
386 assert!(cmd.contains(".ssh"));
387 assert!(cmd.contains("*.log"));
388 assert!(cmd.contains("secrets"));
389 assert!(cmd.contains("info/exclude"));
390 assert!(
392 cmd.contains(".execkit"),
393 ".execkit must appear in the excludes to prevent shadow git-dir self-capture"
394 );
395 }
396
397 #[test]
398 fn execkit_dir_excluded_as_non_negotiable_secret_ignore() {
399 let mut cp = Checkpointer::new("abc123", true, Some("/home/user".into()), vec![".".into()]);
402 cp.set_ignores(vec!["!.execkit".into()]);
403 let cmd = cp.init_cmd("/home/user");
404
405 assert!(cmd.contains("!.execkit"), "user negation must appear");
407 assert!(cmd.contains(".execkit"), "secret exclude must appear");
408
409 let last_pos = cmd
412 .rfind(".execkit")
413 .expect(".execkit not found in command");
414 let byte_before = if last_pos > 0 {
415 cmd.as_bytes().get(last_pos - 1).copied()
416 } else {
417 None
418 };
419 assert_ne!(
420 byte_before,
421 Some(b'!'),
422 "last occurrence of .execkit must not be a negation; SECRET_IGNORES must trail user patterns"
423 );
424 }
425
426 #[test]
427 fn init_cmd_secret_excludes_appear_after_user_negation() {
428 let mut cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
432 cp.set_ignores(vec!["!.ssh".into()]);
433 let cmd = cp.init_cmd("/srv/app");
434
435 assert!(
437 cmd.contains("!.ssh"),
438 "user negation must appear in command"
439 );
440 assert!(
441 cmd.contains(".ssh"),
442 "secret exclude must appear in command"
443 );
444
445 let last_ssh_pos = cmd.rfind(".ssh").expect("`.ssh` not found in command");
448 let byte_before = if last_ssh_pos > 0 {
450 cmd.as_bytes().get(last_ssh_pos - 1).copied()
451 } else {
452 None
453 };
454 assert_ne!(
455 byte_before,
456 Some(b'!'),
457 "last occurrence of `.ssh` must not be a negation (`!.ssh`); \
458 SECRET_IGNORES must trail user patterns"
459 );
460 }
461}
462
463#[cfg(test)]
464mod read_only_tests {
465 use super::is_read_only;
466
467 #[test]
468 fn classifies_commands() {
469 assert!(is_read_only("ls -la"));
471 assert!(is_read_only("cat f | grep x"));
472 assert!(is_read_only("ls; cat f"));
473 assert!(is_read_only("/bin/ls /tmp"));
474 assert!(is_read_only("ps aux | grep nginx"));
475 assert!(!is_read_only("rm -rf build"));
477 assert!(!is_read_only("echo hi > f")); assert!(!is_read_only("sed -i s/a/b/ f")); assert!(!is_read_only("ls && rm x")); assert!(!is_read_only("cat $(whoami)")); assert!(!is_read_only("tee f")); assert!(!is_read_only("npm install")); assert!(!is_read_only("find . -delete")); assert!(!is_read_only("env X=1 rm -rf y")); assert!(!is_read_only("uptime\nrm -rf /tmp/x")); assert!(!is_read_only("cat <(rm x)")); assert!(!is_read_only("uniq in out")); }
490}