1use crate::workspace::{
8 LocalWorkspaceAccessBoundary, LocalWorkspaceAccessPolicy, LocalWorkspaceBackend,
9 WorkspaceGitRemoveWorktreeRequest, WorkspaceGitWorktreeProvider,
10};
11use anyhow::{anyhow, Result};
12use std::collections::HashMap;
13use std::path::{Component, Path, PathBuf};
14use std::sync::{Mutex, OnceLock};
15
16use crate::content_digest::digest_bytes;
17
18const ISOLATION_UNAVAILABLE: &str = "isolation unavailable";
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct IsolationBinding {
22 pub session_id: String,
23 pub source_root: PathBuf,
24 pub worktree_path: PathBuf,
25 pub source_revision: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum PromoteOutcome {
30 Applied {
31 digest: String,
32 },
33 Idempotent {
34 digest: String,
35 },
36 Conflict {
37 bound_revision: String,
38 current_revision: String,
39 },
40}
41
42#[derive(Debug, Default)]
43struct IsolationState {
44 bindings: HashMap<String, IsolationBinding>,
45 applied: HashMap<String, String>,
46}
47
48fn state() -> &'static Mutex<IsolationState> {
49 static STATE: OnceLock<Mutex<IsolationState>> = OnceLock::new();
50 STATE.get_or_init(|| Mutex::new(IsolationState::default()))
51}
52
53pub fn skip_for_read_only(can_write: bool) -> bool {
55 !can_write
56}
57
58pub fn bind_sync(
61 session_id: &str,
62 source_root: &Path,
63 requested: bool,
64 can_write: bool,
65) -> Result<Option<IsolationBinding>> {
66 if !requested || skip_for_read_only(can_write) {
67 return Ok(None);
68 }
69 if !is_git_repository(source_root) {
70 return Err(anyhow!("{ISOLATION_UNAVAILABLE}"));
71 }
72 let revision = source_revision(source_root)?;
73 let worktree_path = worktree_path_for(source_root, session_id);
74 if worktree_path.starts_with(source_root) {
75 return Err(anyhow!(
76 "{ISOLATION_UNAVAILABLE}: worktree path must not sit inside the source tree"
77 ));
78 }
79 refuse_retargeted_worktree(source_root, &worktree_path)?;
80 refuse_symlink_worktree(&worktree_path)?;
81 if let Some(existing) = state()
82 .lock()
83 .expect("isolation state")
84 .bindings
85 .get(session_id)
86 .cloned()
87 {
88 if existing.source_root == source_root && existing.worktree_path.exists() {
89 register_shell_on_worktree(session_id, &existing.worktree_path);
90 return Ok(Some(existing));
91 }
92 }
93 if worktree_path.join(".git").exists() {
94 let binding = IsolationBinding {
95 session_id: session_id.to_string(),
96 source_root: source_root.to_path_buf(),
97 worktree_path,
98 source_revision: revision,
99 };
100 state()
101 .lock()
102 .expect("isolation state")
103 .bindings
104 .insert(session_id.to_string(), binding.clone());
105 register_shell_on_worktree(session_id, &binding.worktree_path);
106 return Ok(Some(binding));
107 }
108 if worktree_path.exists() {
113 std::fs::remove_dir_all(&worktree_path).map_err(|error| {
114 anyhow!(
115 "{ISOLATION_UNAVAILABLE}: stale isolation path {} could not be cleared: {error}",
116 worktree_path.display()
117 )
118 })?;
119 }
120 crate::git::create_worktree(
121 source_root,
122 &format!("a3s-isolate-{session_id}"),
123 &worktree_path,
124 true,
125 )?;
126 let binding = IsolationBinding {
127 session_id: session_id.to_string(),
128 source_root: source_root.to_path_buf(),
129 worktree_path,
130 source_revision: revision,
131 };
132 state()
133 .lock()
134 .expect("isolation state")
135 .bindings
136 .insert(session_id.to_string(), binding.clone());
137 register_shell_on_worktree(session_id, &binding.worktree_path);
138 Ok(Some(binding))
139}
140
141pub async fn bind(
143 session_id: &str,
144 source_root: &Path,
145 can_write: bool,
146) -> Result<IsolationBinding> {
147 bind_sync(session_id, source_root, true, can_write)?
148 .ok_or_else(|| anyhow!("isolation is not created for a session that cannot write"))
149}
150
151pub fn current_change_digest(session_id: &str) -> Result<Option<String>> {
156 let bound = binding(session_id)
157 .ok_or_else(|| anyhow!("isolation unavailable: conversation {session_id} is not bound"))?;
158 let changes = capture_change_set(&bound)?;
159 if changes.is_empty() {
160 return Ok(None);
161 }
162 Ok(Some(changes.digest()))
163}
164
165pub fn promote_current(session_id: &str) -> Result<PromoteOutcome> {
171 let bound = binding(session_id)
172 .ok_or_else(|| anyhow!("isolation unavailable: conversation {session_id} is not bound"))?;
173 let changes = capture_change_set(&bound)?;
174 if changes.is_empty() {
175 return Err(anyhow!("nothing to promote"));
176 }
177 let digest = changes.digest();
178 let current = source_revision(&bound.source_root)?;
179 promote(session_id, &digest, ¤t, move |binding| {
180 changes.apply(binding)
181 })
182}
183
184enum PromoteFile {
185 Write { path: String, content: Vec<u8> },
186 Delete { path: String },
187}
188
189struct IsolatedChangeSet {
190 files: Vec<PromoteFile>,
191}
192
193impl IsolatedChangeSet {
194 fn is_empty(&self) -> bool {
195 self.files.is_empty()
196 }
197
198 fn digest(&self) -> String {
199 let mut bytes = Vec::new();
200 for file in &self.files {
201 match file {
202 PromoteFile::Write { path, content } => {
203 bytes.extend(b"W\0");
204 bytes.extend(path.as_bytes());
205 bytes.push(0);
206 bytes.extend(content);
207 bytes.push(0);
208 }
209 PromoteFile::Delete { path } => {
210 bytes.extend(b"D\0");
211 bytes.extend(path.as_bytes());
212 bytes.push(0);
213 }
214 }
215 }
216 digest_bytes("a3s.code.isolation-promote.v1", &bytes)
217 }
218
219 fn apply(self, binding: &IsolationBinding) -> Result<()> {
220 let boundary = LocalWorkspaceAccessBoundary::for_policy(
221 LocalWorkspaceAccessPolicy::CredentialBoundary,
222 &binding.source_root,
223 )
224 .ok_or_else(|| anyhow!("credential boundary unavailable for isolation promote"))?;
225 for file in self.files {
226 match file {
227 PromoteFile::Write { path, content } => {
228 write_promoted_file(
229 &binding.session_id,
230 &binding.source_root,
231 &path,
232 &content,
233 &boundary,
234 )?;
235 }
236 PromoteFile::Delete { path } => {
237 let relative = safe_relative_path(&path)?;
238 refuse_symlink_components(&binding.source_root, &relative)?;
239 boundary
240 .refuse_promote(&binding.source_root, &relative)
241 .map_err(|error| anyhow!("refusing to promote: {error}"))?;
242 let destination = binding.source_root.join(&relative);
243 if destination.is_file() {
244 let claim =
245 begin_promote_claim(&binding.session_id, &binding.source_root, &path)?;
246 if let Err(error) = std::fs::remove_file(&destination) {
247 claim.release_if_new();
248 return Err(anyhow!(
249 "failed to remove {}: {error}",
250 destination.display()
251 ));
252 }
253 }
254 }
255 }
256 }
257 Ok(())
258 }
259}
260
261fn capture_change_set(binding: &IsolationBinding) -> Result<IsolatedChangeSet> {
262 let listing = git_stdout(
263 &binding.worktree_path,
264 &[
265 "diff",
266 "--name-status",
267 "--no-renames",
268 "-z",
269 binding.source_revision.as_str(),
270 ],
271 )?;
272 let mut files = Vec::new();
273 let mut seen = std::collections::HashSet::new();
274 let mut records = listing
275 .split(|byte| *byte == 0)
276 .filter(|raw| !raw.is_empty());
277 while let Some(status) = records.next() {
278 let path_raw = records
279 .next()
280 .ok_or_else(|| anyhow!("isolation diff listed a status without a path"))?;
281 let status = String::from_utf8(status.to_vec())
282 .map_err(|_| anyhow!("isolation diff listed a non-utf8 status"))?;
283 let path = String::from_utf8(path_raw.to_vec())
284 .map_err(|_| anyhow!("isolation diff listed a non-utf8 path"))?;
285 let kind = status
286 .chars()
287 .next()
288 .ok_or_else(|| anyhow!("isolation diff listed an empty status"))?;
289 match kind {
290 'A' | 'M' | 'T' => {
291 files.push(read_promoted_file(&binding.worktree_path, &path)?);
292 }
293 'D' => files.push(PromoteFile::Delete { path: path.clone() }),
294 _ => {
295 return Err(anyhow!(
296 "refusing to promote unsupported git status {status}"
297 ))
298 }
299 }
300 seen.insert(path);
301 }
302 let untracked = git_stdout(
303 &binding.worktree_path,
304 &["ls-files", "--others", "--exclude-standard", "-z"],
305 )?;
306 for raw in untracked.split(|byte| *byte == 0) {
307 if raw.is_empty() {
308 continue;
309 }
310 let path = String::from_utf8(raw.to_vec())
311 .map_err(|_| anyhow!("isolation worktree listed a non-utf8 path"))?;
312 if seen.contains(&path) {
313 continue;
314 }
315 files.push(read_promoted_file(&binding.worktree_path, &path)?);
316 }
317 files.sort_by(|left, right| left.path().cmp(right.path()));
318 Ok(IsolatedChangeSet { files })
319}
320
321impl PromoteFile {
322 fn path(&self) -> &str {
323 match self {
324 Self::Write { path, .. } | Self::Delete { path } => path,
325 }
326 }
327}
328
329fn read_promoted_file(root: &Path, path: &str) -> Result<PromoteFile> {
330 let relative = safe_relative_path(path)?;
331 refuse_symlink_components(root, &relative)?;
332 let file = root.join(&relative);
333 let metadata = std::fs::symlink_metadata(&file)
334 .map_err(|error| anyhow!("failed to read isolated {}: {error}", file.display()))?;
335 if !metadata.is_file() {
336 return Err(anyhow!("refusing to promote non-regular file {path}"));
337 }
338 let content = std::fs::read(&file)
339 .map_err(|error| anyhow!("failed to read isolated {}: {error}", file.display()))?;
340 Ok(PromoteFile::Write {
341 path: path.to_string(),
342 content,
343 })
344}
345
346struct PromoteClaim {
347 session_id: String,
348 root: PathBuf,
349 path: String,
350 newly_acquired: bool,
351}
352
353impl PromoteClaim {
354 fn release_if_new(&self) {
355 if self.newly_acquired {
356 crate::external_observation::release_write_claim(
357 &self.session_id,
358 &self.root,
359 &self.path,
360 );
361 }
362 }
363}
364
365fn begin_promote_claim(session_id: &str, root: &Path, path: &str) -> Result<PromoteClaim> {
368 let newly_acquired = !crate::external_observation::session_owns_write(session_id, root, path);
369 crate::external_observation::claim_bound_write(Some(session_id), root, path)
370 .map_err(|error| anyhow!(error))?;
371 Ok(PromoteClaim {
372 session_id: session_id.to_string(),
373 root: root.to_path_buf(),
374 path: path.to_string(),
375 newly_acquired,
376 })
377}
378
379fn write_promoted_file(
380 session_id: &str,
381 root: &Path,
382 path: &str,
383 content: &[u8],
384 boundary: &LocalWorkspaceAccessBoundary,
385) -> Result<()> {
386 let relative = safe_relative_path(path)?;
387 refuse_symlink_components(root, &relative)?;
388 boundary
389 .refuse_promote(root, &relative)
390 .map_err(|error| anyhow!("refusing to promote: {error}"))?;
391 let destination = root.join(&relative);
392 if let Some(parent) = destination.parent() {
393 std::fs::create_dir_all(parent)
394 .map_err(|error| anyhow!("failed to create {}: {error}", parent.display()))?;
395 }
396 let claim = begin_promote_claim(session_id, root, path)?;
397 if let Err(error) = std::fs::write(&destination, content) {
398 claim.release_if_new();
399 return Err(anyhow!(
400 "failed to promote {}: {error}",
401 destination.display()
402 ));
403 }
404 Ok(())
405}
406
407fn git_stdout(root: &Path, args: &[&str]) -> Result<Vec<u8>> {
408 let _guard = git_process_lock()
409 .lock()
410 .unwrap_or_else(|error| error.into_inner());
411 git_stdout_unlocked(root, args)
412}
413
414fn git_stdout_unlocked(root: &Path, args: &[&str]) -> Result<Vec<u8>> {
415 let output = std::process::Command::new("git")
416 .arg("-C")
417 .arg(root)
418 .args(args)
419 .output()
420 .map_err(|error| {
421 anyhow!(
422 "git {} failed: {error}",
423 args.first().copied().unwrap_or("diff")
424 )
425 })?;
426 if output.status.success() {
427 return Ok(output.stdout);
428 }
429 Err(anyhow!(
430 "{}",
431 String::from_utf8_lossy(&output.stderr).trim()
432 ))
433}
434
435fn refuse_symlink_components(root: &Path, relative: &Path) -> Result<()> {
436 let mut current = root.to_path_buf();
437 for component in relative.components() {
438 let Component::Normal(name) = component else {
439 return Err(anyhow!("refusing to promote unsafe path {relative:?}"));
440 };
441 current.push(name);
442 match std::fs::symlink_metadata(¤t) {
443 Ok(metadata) if metadata.file_type().is_symlink() => {
444 return Err(anyhow!("refusing to promote through a symbolic link"));
445 }
446 Ok(_) => {}
447 Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
448 Err(error) => {
449 return Err(anyhow!(
450 "failed to inspect promote path {}: {error}",
451 current.display()
452 ));
453 }
454 }
455 }
456 Ok(())
457}
458
459fn safe_relative_path(path: &str) -> Result<PathBuf> {
460 let path = Path::new(path);
461 let escapes = path.components().any(|component| {
464 matches!(
465 component,
466 Component::ParentDir | Component::RootDir | Component::Prefix(_)
467 )
468 });
469 if path.is_absolute() || escapes {
470 return Err(anyhow!("refusing to promote unsafe path {path:?}"));
471 }
472 Ok(path.to_path_buf())
473}
474
475pub fn binding(session_id: &str) -> Option<IsolationBinding> {
476 state()
477 .lock()
478 .expect("isolation state")
479 .bindings
480 .get(session_id)
481 .cloned()
482}
483
484pub async fn discard(session_id: &str) -> Result<()> {
489 let binding = state()
490 .lock()
491 .expect("isolation state")
492 .bindings
493 .remove(session_id)
494 .ok_or_else(|| anyhow!("isolation unavailable: no binding for {session_id}"))?;
495 let backend = LocalWorkspaceBackend::new(binding.source_root.clone());
496 match backend
497 .remove_worktree(WorkspaceGitRemoveWorktreeRequest {
498 path: binding.worktree_path.display().to_string(),
499 force: true,
500 })
501 .await
502 {
503 Ok(_) => {}
504 Err(error) => {
505 let message = error.to_string();
506 let already_gone = !binding.worktree_path.exists()
507 || message.contains("is not a working tree")
508 || message.contains("not a valid path");
509 if !already_gone {
510 state()
512 .lock()
513 .expect("isolation state")
514 .bindings
515 .insert(session_id.to_string(), binding);
516 return Err(error);
517 }
518 let _ = std::fs::remove_dir_all(&binding.worktree_path);
519 }
520 }
521 crate::shell_session::drop_session(session_id);
522 Ok(())
523}
524
525pub fn promote<F>(
527 session_id: &str,
528 digest: &str,
529 current_revision: &str,
530 apply: F,
531) -> Result<PromoteOutcome>
532where
533 F: FnOnce(&IsolationBinding) -> Result<()>,
534{
535 if digest.trim().is_empty() {
536 return Err(anyhow!("promote requires a change-set digest"));
537 }
538 let guard = state().lock().expect("isolation state");
539 if guard.applied.contains_key(digest) {
542 return Ok(PromoteOutcome::Idempotent {
543 digest: digest.to_string(),
544 });
545 }
546 let binding = guard
547 .bindings
548 .get(session_id)
549 .cloned()
550 .ok_or_else(|| anyhow!("isolation unavailable: no binding for {session_id}"))?;
551 if current_revision != binding.source_revision {
552 return Ok(PromoteOutcome::Conflict {
553 bound_revision: binding.source_revision,
554 current_revision: current_revision.to_string(),
555 });
556 }
557 drop(guard);
558 apply(&binding)?;
559 state()
560 .lock()
561 .expect("isolation state")
562 .applied
563 .insert(digest.to_string(), session_id.to_string());
564 Ok(PromoteOutcome::Applied {
565 digest: digest.to_string(),
566 })
567}
568
569pub fn refuse_retargeted_worktree(source_root: &Path, worktree_path: &Path) -> Result<()> {
570 let expected_parent = source_root.parent().unwrap_or(source_root);
571 let name = worktree_path
572 .file_name()
573 .and_then(|name| name.to_str())
574 .unwrap_or("");
575 let single_sibling = worktree_path.parent() == Some(expected_parent)
576 && name.starts_with(".a3s-isolate-")
577 && !name.contains(['/', '\\'])
578 && !worktree_path
579 .components()
580 .any(|component| matches!(component, Component::ParentDir | Component::CurDir));
581 if single_sibling {
582 return Ok(());
583 }
584 Err(anyhow!(
585 "{ISOLATION_UNAVAILABLE}: worktree path must be a sibling of the source tree"
586 ))
587}
588
589fn refuse_symlink_worktree(path: &Path) -> Result<()> {
590 if path
591 .symlink_metadata()
592 .is_ok_and(|metadata| metadata.file_type().is_symlink())
593 {
594 return Err(anyhow!(
595 "{ISOLATION_UNAVAILABLE}: worktree path is a symlink"
596 ));
597 }
598 Ok(())
599}
600
601pub fn worktree_path_for(source_root: &Path, session_id: &str) -> PathBuf {
606 let parent = source_root.parent().unwrap_or(source_root);
607 parent.join(format!(".a3s-isolate-{session_id}"))
608}
609
610fn git_process_lock() -> &'static std::sync::Mutex<()> {
611 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
612 &LOCK
613}
614
615fn is_git_repository(root: &Path) -> bool {
616 let _guard = git_process_lock()
617 .lock()
618 .unwrap_or_else(|error| error.into_inner());
619 std::process::Command::new("git")
620 .arg("-C")
621 .arg(root)
622 .args(["rev-parse", "--git-dir"])
623 .output()
624 .is_ok_and(|output| output.status.success())
625}
626
627fn source_revision(root: &Path) -> Result<String> {
628 let _guard = git_process_lock()
629 .lock()
630 .unwrap_or_else(|error| error.into_inner());
631 let output = std::process::Command::new("git")
632 .arg("-C")
633 .arg(root)
634 .args(["rev-parse", "HEAD"])
635 .output()?;
636 if !output.status.success() {
637 return Err(anyhow!(
638 "{ISOLATION_UNAVAILABLE}: source revision is unknown"
639 ));
640 }
641 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
642}
643
644fn register_shell_on_worktree(session_id: &str, worktree: &Path) {
645 crate::shell_session::bind_session_rooted(session_id, worktree, worktree);
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use std::fs;
652 use std::sync::atomic::{AtomicUsize, Ordering};
653
654 fn init_repo(root: &Path) {
655 fs::create_dir_all(root).unwrap();
656 git(root, &["init"]);
657 git(root, &["config", "user.email", "a3s@example.com"]);
658 git(root, &["config", "user.name", "a3s"]);
659 fs::write(root.join("README.md"), "source\n").unwrap();
660 git(root, &["add", "README.md"]);
661 git(root, &["commit", "-m", "init"]);
662 }
663
664 fn git(root: &Path, args: &[&str]) {
665 let _guard = super::git_process_lock()
666 .lock()
667 .unwrap_or_else(|error| error.into_inner());
668 let status = std::process::Command::new("git")
669 .arg("-C")
670 .arg(root)
671 .args(args)
672 .status()
673 .expect("git");
674 assert!(status.success(), "git {args:?} failed");
675 }
676
677 #[tokio::test]
678 async fn bind_clears_a_stale_empty_isolation_directory() {
679 forget_leaked_session("stale-empty");
680 let root = tempfile::tempdir().unwrap();
681 init_repo(root.path());
682 let stale = worktree_path_for(root.path(), "stale-empty");
683 fs::create_dir_all(&stale).unwrap();
684 assert!(!stale.join(".git").exists());
685
686 let binding = bind("stale-empty", root.path(), true)
687 .await
688 .expect("orphan empty sibling must be cleared and recreated");
689 assert_eq!(binding.worktree_path, stale);
690 assert!(stale.join(".git").exists());
691 discard("stale-empty").await.unwrap();
692 }
693
694 #[tokio::test]
698 async fn bind_discard_does_not_leak_into_source() {
699 let root = tempfile::tempdir().unwrap();
700 init_repo(root.path());
701 let source = fs::read(root.path().join("README.md")).unwrap();
702 for i in 0..2 {
703 let id = format!("leak-ei-{i}");
704 forget_leaked_session(&id);
705 let binding = bind(&id, root.path(), true)
706 .await
707 .unwrap_or_else(|error| panic!("bind {id}: {error}"));
708 fs::write(binding.worktree_path.join("leak.txt"), b"nope").unwrap();
709 discard(&id)
710 .await
711 .unwrap_or_else(|error| panic!("discard {id}: {error}"));
712 assert!(
713 !root.path().join("leak.txt").exists(),
714 "discard cycle {i} wrote the source tree"
715 );
716 assert!(
717 !worktree_path_for(root.path(), &id).exists(),
718 "discard cycle {i} left an isolation directory"
719 );
720 }
721 assert_eq!(fs::read(root.path().join("README.md")).unwrap(), source);
722 }
723
724 #[tokio::test]
725 async fn two_sessions_do_not_share_a_writable_tree() {
726 let root = tempfile::tempdir().unwrap();
727 init_repo(root.path());
728 let left = bind("left", root.path(), true).await.unwrap();
729 let right = bind("right", root.path(), true).await.unwrap();
730 assert_ne!(left.worktree_path, right.worktree_path);
731 fs::write(left.worktree_path.join("left.txt"), "left").unwrap();
732 fs::write(right.worktree_path.join("right.txt"), "right").unwrap();
733 assert!(!root.path().join("left.txt").exists());
734 assert!(!root.path().join("right.txt").exists());
735 assert!(!left.worktree_path.join("right.txt").exists());
736 discard("left").await.unwrap();
737 discard("right").await.unwrap();
738 }
739
740 #[tokio::test]
741 async fn rebind_of_the_same_session_reuses_the_worktree() {
742 let root = tempfile::tempdir().unwrap();
743 init_repo(root.path());
744 let first = bind_sync("rebind-session", root.path(), true, true)
745 .unwrap()
746 .expect("worktree");
747 let second = bind_sync("rebind-session", root.path(), true, true)
748 .unwrap()
749 .expect("reused worktree");
750 assert_eq!(first.worktree_path, second.worktree_path);
751 assert!(!root
752 .path()
753 .join(".git")
754 .join("worktrees")
755 .read_dir()
756 .unwrap()
757 .next()
758 .is_none());
759 discard("rebind-session").await.unwrap();
760 }
761
762 #[tokio::test]
763 async fn bind_adopts_an_existing_worktree_directory_with_git_metadata() {
764 let root = tempfile::tempdir().unwrap();
765 init_repo(root.path());
766 let session_id = "adopt-existing-wt";
767 let worktree = worktree_path_for(root.path(), session_id);
768 fs::create_dir_all(&worktree).unwrap();
769 fs::write(worktree.join(".git"), "gitdir: ../.git/worktrees/adopt\n").unwrap();
771 let binding = bind_sync(session_id, root.path(), true, true)
772 .unwrap()
773 .expect("adopted worktree");
774 assert_eq!(binding.worktree_path, worktree);
775 discard(session_id).await.unwrap();
776 }
777
778 #[tokio::test]
779 async fn discard_leaves_the_source_tree_unchanged() {
780 let root = tempfile::tempdir().unwrap();
781 init_repo(root.path());
782 let binding = bind("discard", root.path(), true).await.unwrap();
783 fs::write(binding.worktree_path.join("noise.txt"), "noise").unwrap();
784 discard("discard").await.unwrap();
785 assert_eq!(
786 fs::read_to_string(root.path().join("README.md")).unwrap(),
787 "source\n"
788 );
789 assert!(!root.path().join("noise.txt").exists());
790 }
791
792 #[tokio::test]
793 async fn discard_is_idempotent_when_worktree_is_already_unregistered() {
794 let root = tempfile::tempdir().unwrap();
795 init_repo(root.path());
796 let binding = bind("discard-gone", root.path(), true).await.unwrap();
797 let _ = std::process::Command::new("git")
800 .args(["worktree", "remove", "--force"])
801 .arg(&binding.worktree_path)
802 .current_dir(root.path())
803 .status();
804 let _ = fs::remove_dir_all(&binding.worktree_path);
805 discard("discard-gone")
806 .await
807 .expect("discard already-gone worktree");
808 assert!(!binding.worktree_path.exists());
809 assert!(crate::effect_isolation::binding("discard-gone").is_none());
810 }
811
812 #[cfg(unix)]
813 #[tokio::test]
814 async fn bind_does_not_reuse_a_symlink_as_the_isolation_worktree() {
815 let parent = tempfile::tempdir().unwrap();
816 let source = parent.path().join("repo");
817 init_repo(&source);
818 let session_id = format!("isolate-symlink-{}", std::process::id());
819 let sibling = worktree_path_for(&source, &session_id);
820 std::os::unix::fs::symlink(&source, &sibling).unwrap();
821
822 let bound = bind_sync(&session_id, &source, true, true);
823
824 assert!(
825 bound.is_err(),
826 "a symlink sibling must not become the isolation worktree: {bound:?}"
827 );
828 assert_eq!(
829 fs::read_to_string(source.join("README.md")).unwrap(),
830 "source\n"
831 );
832 let _ = std::fs::remove_file(&sibling);
833 }
834
835 #[tokio::test]
836 async fn bind_does_not_place_the_worktree_inside_the_source_via_session_id() {
837 let parent = tempfile::tempdir().unwrap();
838 let source = parent.path().join("repo");
839 init_repo(&source);
840 fs::create_dir(parent.path().join(".a3s-isolate-nested")).unwrap();
841 let session_id = "nested/../repo".to_string();
842
843 let bound = bind_sync(&session_id, &source, true, true);
844
845 assert!(
846 bound.is_err(),
847 "a session id must not retarget the isolation worktree onto the source tree: {bound:?}"
848 );
849 assert_eq!(
850 fs::read_to_string(source.join("README.md")).unwrap(),
851 "source\n"
852 );
853 }
854
855 #[tokio::test]
856 async fn isolation_shell_cd_does_not_leave_the_worktree() {
857 let root = tempfile::tempdir().unwrap();
858 init_repo(root.path());
859 let session_id = format!("isolate-cd-{}", std::process::id());
860 let binding = bind(&session_id, root.path(), true).await.unwrap();
861 let source_cd = format!("cd {}", root.path().display());
862
863 let climbed = crate::shell_session::admit(
864 &session_id,
865 "cd ..",
866 crate::shell_session::CommandAdmission::Allow,
867 );
868 let absolute = crate::shell_session::admit(
869 &session_id,
870 &source_cd,
871 crate::shell_session::CommandAdmission::Allow,
872 );
873
874 assert!(
875 climbed.is_err(),
876 "cd out of the isolation worktree must not be admitted: {climbed:?}"
877 );
878 assert!(
879 absolute.is_err(),
880 "cd onto the source tree must not be admitted: {absolute:?}"
881 );
882 assert_eq!(
883 crate::shell_session::cwd(&session_id).as_deref(),
884 Some(binding.worktree_path.as_path())
885 );
886 fs::create_dir(binding.worktree_path.join("nested")).unwrap();
887 crate::shell_session::admit(
888 &session_id,
889 "cd nested",
890 crate::shell_session::CommandAdmission::Allow,
891 )
892 .expect("cd inside the worktree stays admitted");
893 assert_eq!(
894 crate::shell_session::cwd(&session_id).as_deref(),
895 Some(binding.worktree_path.join("nested").as_path())
896 );
897 assert_eq!(
898 fs::read_to_string(root.path().join("README.md")).unwrap(),
899 "source\n"
900 );
901 discard(&session_id).await.unwrap();
902 }
903
904 #[tokio::test]
905 async fn promote_conflicts_when_source_moves_and_does_not_apply() {
906 let root = tempfile::tempdir().unwrap();
907 init_repo(root.path());
908 let binding = bind("promote", root.path(), true).await.unwrap();
909 fs::write(root.path().join("README.md"), "moved\n").unwrap();
910 git(root.path(), &["add", "README.md"]);
911 git(root.path(), &["commit", "-m", "move"]);
912 let current = source_revision(root.path()).unwrap();
913 let applied = AtomicUsize::new(0);
914 let outcome = promote("promote", "digest-1", ¤t, |_| {
915 applied.fetch_add(1, Ordering::SeqCst);
916 Ok(())
917 })
918 .unwrap();
919 assert!(matches!(outcome, PromoteOutcome::Conflict { .. }));
920 assert_eq!(applied.load(Ordering::SeqCst), 0);
921 assert_eq!(
922 fs::read_to_string(root.path().join("README.md")).unwrap(),
923 "moved\n"
924 );
925 let _ = binding;
926 discard("promote").await.unwrap();
927 }
928
929 #[test]
930 fn replay_of_the_same_digest_is_idempotent() {
931 let root = tempfile::tempdir().unwrap();
932 init_repo(root.path());
933 let binding = IsolationBinding {
934 session_id: "idem".into(),
935 source_root: root.path().to_path_buf(),
936 worktree_path: root.path().join("elsewhere"),
937 source_revision: "rev-1".into(),
938 };
939 state()
940 .lock()
941 .unwrap()
942 .bindings
943 .insert("idem".into(), binding);
944 let applied = AtomicUsize::new(0);
945 let first = promote("idem", "same", "rev-1", |_| {
946 applied.fetch_add(1, Ordering::SeqCst);
947 Ok(())
948 })
949 .unwrap();
950 let second = promote("idem", "same", "rev-1", |_| {
951 applied.fetch_add(1, Ordering::SeqCst);
952 Ok(())
953 })
954 .unwrap();
955 assert!(matches!(first, PromoteOutcome::Applied { .. }));
956 assert!(matches!(second, PromoteOutcome::Idempotent { .. }));
957 assert_eq!(applied.load(Ordering::SeqCst), 1);
958 state().lock().unwrap().bindings.remove("idem");
959 state().lock().unwrap().applied.remove("same");
960 }
961
962 #[test]
963 fn replay_of_the_same_digest_from_another_session_does_not_apply_again() {
964 let root = tempfile::tempdir().unwrap();
965 init_repo(root.path());
966 for session_id in ["idem-a", "idem-b"] {
967 state().lock().unwrap().bindings.insert(
968 session_id.into(),
969 IsolationBinding {
970 session_id: session_id.into(),
971 source_root: root.path().to_path_buf(),
972 worktree_path: root.path().join(session_id),
973 source_revision: "rev-1".into(),
974 },
975 );
976 }
977 let applied = AtomicUsize::new(0);
978 let first = promote("idem-a", "shared-digest", "rev-1", |_| {
979 applied.fetch_add(1, Ordering::SeqCst);
980 Ok(())
981 })
982 .unwrap();
983 let second = promote("idem-b", "shared-digest", "rev-1", |_| {
984 applied.fetch_add(1, Ordering::SeqCst);
985 Ok(())
986 })
987 .unwrap();
988 assert!(matches!(first, PromoteOutcome::Applied { .. }));
989 assert!(
990 matches!(second, PromoteOutcome::Idempotent { .. }),
991 "a digest already applied by another session is a replay, not a second apply"
992 );
993 assert_eq!(applied.load(Ordering::SeqCst), 1);
994 let mut guard = state().lock().unwrap();
995 guard.bindings.remove("idem-a");
996 guard.bindings.remove("idem-b");
997 guard.applied.remove("shared-digest");
998 }
999
1000 #[tokio::test]
1001 async fn non_git_root_fails_closed_without_writing_it() {
1002 let root = tempfile::tempdir().unwrap();
1003 let error = bind("bare", root.path(), true).await.unwrap_err();
1004 assert!(error.to_string().contains(ISOLATION_UNAVAILABLE));
1005 assert!(fs::read_dir(root.path()).unwrap().next().is_none());
1006 }
1007
1008 #[tokio::test]
1009 async fn promote_current_writes_the_worktree_only_after_an_unchanged_revision() {
1010 let root = tempfile::tempdir().unwrap();
1011 init_repo(root.path());
1012 let binding = bind("promote-current", root.path(), true).await.unwrap();
1013 fs::write(binding.worktree_path.join("from-isolate.txt"), "isolated\n").unwrap();
1014 fs::write(binding.worktree_path.join("README.md"), "edited\n").unwrap();
1015 assert!(!root.path().join("from-isolate.txt").exists());
1016
1017 let first = promote_current("promote-current").unwrap();
1018 assert!(matches!(first, PromoteOutcome::Applied { .. }));
1019 assert_eq!(
1020 fs::read_to_string(root.path().join("from-isolate.txt")).unwrap(),
1021 "isolated\n"
1022 );
1023 assert_eq!(
1024 fs::read_to_string(root.path().join("README.md")).unwrap(),
1025 "edited\n"
1026 );
1027
1028 let replay = promote_current("promote-current").unwrap();
1029 assert!(matches!(replay, PromoteOutcome::Idempotent { .. }));
1030 assert_eq!(
1031 fs::read_to_string(root.path().join("from-isolate.txt")).unwrap(),
1032 "isolated\n"
1033 );
1034
1035 fs::write(binding.worktree_path.join("second.txt"), "again\n").unwrap();
1036 let second = promote_current("promote-current").unwrap();
1037 assert!(matches!(second, PromoteOutcome::Applied { .. }));
1038 assert_eq!(
1039 fs::read_to_string(root.path().join("second.txt")).unwrap(),
1040 "again\n"
1041 );
1042 assert_eq!(
1043 fs::read_to_string(root.path().join("from-isolate.txt")).unwrap(),
1044 "isolated\n"
1045 );
1046 discard("promote-current").await.unwrap();
1047 }
1048
1049 fn forget_leaked_session(session_id: &str) {
1050 if let Some(binding) = state()
1051 .lock()
1052 .expect("isolation state")
1053 .bindings
1054 .remove(session_id)
1055 {
1056 let _ = fs::remove_dir_all(binding.worktree_path);
1057 }
1058 let _ = fs::remove_dir_all(std::env::temp_dir().join(format!(".a3s-isolate-{session_id}")));
1059 crate::external_observation::release_session(session_id);
1060 }
1061
1062 #[tokio::test]
1063 async fn promote_owns_a_path_it_wrote_so_another_session_cannot_overwrite_it() {
1064 forget_leaked_session("promote-owner");
1065 forget_leaked_session("promote-other");
1066 forget_leaked_session("promote-late");
1067 let root = tempfile::tempdir().unwrap();
1068 init_repo(root.path());
1069 let owner = bind("promote-owner", root.path(), true).await.unwrap();
1070 fs::write(owner.worktree_path.join("guest.txt"), "owned-by-promote\n").unwrap();
1071 let applied = promote_current("promote-owner").unwrap();
1072 assert!(matches!(applied, PromoteOutcome::Applied { .. }));
1073 assert_eq!(
1074 fs::read_to_string(root.path().join("guest.txt")).unwrap(),
1075 "owned-by-promote\n"
1076 );
1077
1078 let blocked = crate::external_observation::claim_bound_write(
1079 Some("promote-late"),
1080 root.path(),
1081 "guest.txt",
1082 );
1083 assert!(
1084 blocked.is_err(),
1085 "promote must own the path it wrote, not leave it for another session"
1086 );
1087
1088 let other = bind("promote-other", root.path(), true).await.unwrap();
1089 fs::write(other.worktree_path.join("guest.txt"), "stolen\n").unwrap();
1090 let stolen = promote_current("promote-other");
1091 assert!(
1092 stolen.is_err(),
1093 "a second session must not promote over a path the first session just wrote"
1094 );
1095 assert_eq!(
1096 fs::read_to_string(root.path().join("guest.txt")).unwrap(),
1097 "owned-by-promote\n"
1098 );
1099 discard("promote-owner").await.unwrap();
1100 discard("promote-other").await.unwrap();
1101 crate::external_observation::release_session("promote-owner");
1102 crate::external_observation::release_session("promote-other");
1103 crate::external_observation::release_session("promote-late");
1104 }
1105
1106 #[tokio::test]
1107 async fn promote_current_conflict_does_not_copy_the_worktree() {
1108 let root = tempfile::tempdir().unwrap();
1109 init_repo(root.path());
1110 let binding = bind("promote-conflict", root.path(), true).await.unwrap();
1111 fs::write(binding.worktree_path.join("from-isolate.txt"), "isolated\n").unwrap();
1112 fs::write(root.path().join("README.md"), "moved\n").unwrap();
1113 git(root.path(), &["add", "README.md"]);
1114 git(root.path(), &["commit", "-m", "move"]);
1115
1116 let outcome = promote_current("promote-conflict").unwrap();
1117 assert!(matches!(outcome, PromoteOutcome::Conflict { .. }));
1118 assert!(!root.path().join("from-isolate.txt").exists());
1119 assert_eq!(
1120 fs::read_to_string(root.path().join("README.md")).unwrap(),
1121 "moved\n"
1122 );
1123 discard("promote-conflict").await.unwrap();
1124 }
1125
1126 #[test]
1127 fn unsafe_promote_paths_are_rejected() {
1128 assert!(safe_relative_path("../escape").is_err());
1129 assert!(safe_relative_path("/tmp/escape").is_err());
1130 assert!(safe_relative_path("nested/ok.txt").is_ok());
1131 }
1132
1133 #[cfg(unix)]
1134 #[tokio::test]
1135 async fn promote_does_not_follow_a_symlink_file_on_the_source_tree() {
1136 use std::os::unix::fs::symlink;
1137
1138 let root = tempfile::tempdir().unwrap();
1139 let outside = tempfile::tempdir().unwrap();
1140 let outside_file = outside.path().join("secret.txt");
1141 fs::write(&outside_file, "outside-token-91c4").unwrap();
1142 init_repo(root.path());
1143 let binding = bind("promote-symlink-file", root.path(), true)
1144 .await
1145 .unwrap();
1146 symlink(&outside_file, root.path().join("guest.txt")).unwrap();
1147 fs::write(
1148 binding.worktree_path.join("guest.txt"),
1149 "promoted-through-link",
1150 )
1151 .unwrap();
1152
1153 let error = promote_current("promote-symlink-file").unwrap_err();
1154 assert!(error.to_string().contains("symbolic link"), "{error}");
1155 assert_eq!(
1156 fs::read_to_string(&outside_file).unwrap(),
1157 "outside-token-91c4"
1158 );
1159 discard("promote-symlink-file").await.unwrap();
1160 }
1161
1162 #[cfg(unix)]
1163 #[tokio::test]
1164 async fn promote_does_not_create_directories_through_a_source_symlink() {
1165 use std::os::unix::fs::symlink;
1166
1167 let root = tempfile::tempdir().unwrap();
1168 let outside = tempfile::tempdir().unwrap();
1169 init_repo(root.path());
1170 let binding = bind("promote-symlink-dir", root.path(), true)
1171 .await
1172 .unwrap();
1173 symlink(outside.path(), root.path().join("escape")).unwrap();
1174 fs::create_dir_all(binding.worktree_path.join("escape/nested")).unwrap();
1175 fs::write(
1176 binding.worktree_path.join("escape/nested/new.txt"),
1177 "created-outside",
1178 )
1179 .unwrap();
1180
1181 let error = promote_current("promote-symlink-dir").unwrap_err();
1182 assert!(error.to_string().contains("symbolic link"), "{error}");
1183 assert!(!outside.path().join("nested").exists());
1184 discard("promote-symlink-dir").await.unwrap();
1185 }
1186
1187 #[cfg(unix)]
1188 #[tokio::test]
1189 async fn promote_does_not_delete_through_a_source_symlink() {
1190 use std::os::unix::fs::symlink;
1191
1192 let root = tempfile::tempdir().unwrap();
1193 let outside = tempfile::tempdir().unwrap();
1194 fs::create_dir_all(root.path().join("src")).unwrap();
1195 fs::write(root.path().join("src/secret.txt"), "tracked\n").unwrap();
1196 init_repo(root.path());
1197 git(root.path(), &["add", "src/secret.txt"]);
1198 git(root.path(), &["commit", "-m", "track"]);
1199 let binding = bind("promote-symlink-delete", root.path(), true)
1200 .await
1201 .unwrap();
1202 fs::remove_file(root.path().join("src/secret.txt")).unwrap();
1203 fs::remove_dir(root.path().join("src")).unwrap();
1204 fs::write(outside.path().join("secret.txt"), "outside-token-d17e").unwrap();
1205 symlink(outside.path(), root.path().join("src")).unwrap();
1206 fs::remove_file(binding.worktree_path.join("src/secret.txt")).unwrap();
1207
1208 let error = promote_current("promote-symlink-delete").unwrap_err();
1209 assert!(error.to_string().contains("symbolic link"), "{error}");
1210 assert_eq!(
1211 fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
1212 "outside-token-d17e"
1213 );
1214 discard("promote-symlink-delete").await.unwrap();
1215 }
1216
1217 #[cfg(any(unix, windows))]
1218 #[tokio::test]
1219 async fn promote_does_not_write_through_a_source_hardlink() {
1220 let root = tempfile::tempdir().unwrap();
1221 init_repo(root.path());
1222 fs::write(root.path().join("alias.txt"), "placeholder\n").unwrap();
1223 git(root.path(), &["add", "alias.txt"]);
1224 git(root.path(), &["commit", "-m", "alias"]);
1225 let binding = bind("promote-hardlink", root.path(), true).await.unwrap();
1226 fs::write(
1227 root.path().join("source.txt"),
1228 "promote-hardlink-token-2f8b",
1229 )
1230 .unwrap();
1231 fs::remove_file(root.path().join("alias.txt")).unwrap();
1232 std::fs::hard_link(
1233 root.path().join("source.txt"),
1234 root.path().join("alias.txt"),
1235 )
1236 .unwrap();
1237 fs::write(
1238 binding.worktree_path.join("alias.txt"),
1239 "written-through-promote",
1240 )
1241 .unwrap();
1242
1243 let error = promote_current("promote-hardlink").unwrap_err();
1244 assert!(error.to_string().contains("credential boundary"), "{error}");
1245 assert_eq!(
1246 fs::read_to_string(root.path().join("source.txt")).unwrap(),
1247 "promote-hardlink-token-2f8b"
1248 );
1249 discard("promote-hardlink").await.unwrap();
1250 }
1251
1252 #[cfg(any(unix, windows))]
1253 #[tokio::test]
1254 async fn promote_does_not_overwrite_a_credential_file() {
1255 let root = tempfile::tempdir().unwrap();
1256 init_repo(root.path());
1257 fs::write(root.path().join(".env"), "TOKEN=promote-secret-91aa\n").unwrap();
1258 git(root.path(), &["add", ".env"]);
1259 git(root.path(), &["commit", "-m", "env"]);
1260 let binding = bind("promote-env", root.path(), true).await.unwrap();
1261 fs::write(
1262 binding.worktree_path.join(".env"),
1263 "TOKEN=promoted-secret\n",
1264 )
1265 .unwrap();
1266
1267 let error = promote_current("promote-env").unwrap_err();
1268 assert!(error.to_string().contains("credential boundary"), "{error}");
1269 assert_eq!(
1270 fs::read_to_string(root.path().join(".env")).unwrap(),
1271 "TOKEN=promote-secret-91aa\n"
1272 );
1273 discard("promote-env").await.unwrap();
1274 }
1275
1276 #[cfg(any(unix, windows))]
1277 #[tokio::test]
1278 async fn promote_still_writes_a_package_store_hardlink() {
1279 let root = tempfile::tempdir().unwrap();
1280 init_repo(root.path());
1281 let package = root.path().join("node_modules/pkg");
1282 fs::create_dir_all(&package).unwrap();
1283 fs::write(package.join("alias.js"), "export const value = 1;\n").unwrap();
1284 git(root.path(), &["add", "-f", "node_modules/pkg/alias.js"]);
1285 git(root.path(), &["commit", "-m", "package"]);
1286 let binding = bind("promote-package-link", root.path(), true)
1287 .await
1288 .unwrap();
1289 let source = package.join("source.js");
1290 fs::write(&source, "export const value = 1;\n").unwrap();
1291 fs::remove_file(package.join("alias.js")).unwrap();
1292 std::fs::hard_link(&source, package.join("alias.js")).unwrap();
1293 fs::create_dir_all(binding.worktree_path.join("node_modules/pkg")).unwrap();
1294 fs::write(
1295 binding.worktree_path.join("node_modules/pkg/alias.js"),
1296 "export const value = 2;\n",
1297 )
1298 .unwrap();
1299
1300 let outcome = promote_current("promote-package-link").unwrap();
1301 assert!(matches!(outcome, PromoteOutcome::Applied { .. }));
1302 assert_eq!(
1303 fs::read_to_string(package.join("alias.js")).unwrap(),
1304 "export const value = 2;\n"
1305 );
1306 discard("promote-package-link").await.unwrap();
1307 }
1308
1309 #[tokio::test]
1310 async fn read_only_does_not_create_a_worktree() {
1311 let root = tempfile::tempdir().unwrap();
1312 init_repo(root.path());
1313 let error = bind("ro", root.path(), false).await.unwrap_err();
1314 assert!(error.to_string().contains("cannot write"));
1315 assert!(binding("ro").is_none());
1316 }
1317
1318 #[test]
1319 fn skip_for_read_only_is_true_when_writes_are_disabled() {
1320 assert!(skip_for_read_only(false));
1321 assert!(!skip_for_read_only(true));
1322 }
1323
1324 #[test]
1325 fn bind_sync_returns_none_when_isolation_is_not_requested() {
1326 let root = tempfile::tempdir().unwrap();
1327 init_repo(root.path());
1328 assert!(bind_sync("not-requested", root.path(), false, true)
1329 .unwrap()
1330 .is_none());
1331 assert!(binding("not-requested").is_none());
1332 }
1333
1334 #[tokio::test]
1335 async fn current_change_digest_and_promote_current_cover_empty_and_dirty_paths() {
1336 let missing = current_change_digest("never-bound-digest").unwrap_err();
1337 assert!(missing.to_string().contains("not bound"), "{missing}");
1338
1339 let root = tempfile::tempdir().unwrap();
1340 init_repo(root.path());
1341 let bound = bind("digest-clean", root.path(), true).await.unwrap();
1342 assert!(current_change_digest("digest-clean").unwrap().is_none());
1343 let nothing = promote_current("digest-clean").unwrap_err();
1344 assert!(
1345 nothing.to_string().contains("nothing to promote"),
1346 "{nothing}"
1347 );
1348
1349 fs::write(bound.worktree_path.join("dirty.txt"), "noise").unwrap();
1350 assert!(current_change_digest("digest-clean").unwrap().is_some());
1351 discard("digest-clean").await.unwrap();
1352 }
1353
1354 #[test]
1355 fn promote_rejects_empty_digest() {
1356 let err = promote("missing", " ", "rev", |_| Ok(())).unwrap_err();
1357 assert!(err.to_string().contains("digest"), "{err}");
1358 }
1359
1360 #[tokio::test]
1361 async fn discard_requires_an_existing_binding() {
1362 let missing = discard("never-bound-discard").await.unwrap_err();
1363 assert!(missing.to_string().contains("no binding"), "{missing}");
1364 }
1365
1366 #[tokio::test]
1367 async fn promote_current_deletes_a_tracked_file_removed_in_the_worktree() {
1368 let root = tempfile::tempdir().unwrap();
1369 init_repo(root.path());
1370 fs::write(root.path().join("gone.txt"), "tracked\n").unwrap();
1371 git(root.path(), &["add", "gone.txt"]);
1372 git(root.path(), &["commit", "-m", "track gone"]);
1373 let binding = bind("promote-delete", root.path(), true).await.unwrap();
1374 fs::remove_file(binding.worktree_path.join("gone.txt")).unwrap();
1375
1376 let outcome = promote_current("promote-delete").unwrap();
1377 assert!(matches!(outcome, PromoteOutcome::Applied { .. }));
1378 assert!(!root.path().join("gone.txt").exists());
1379 discard("promote-delete").await.unwrap();
1380 }
1381
1382 #[cfg(unix)]
1383 #[tokio::test]
1384 async fn promote_current_refuses_a_symlink_as_a_promoted_path() {
1385 let root = tempfile::tempdir().unwrap();
1386 init_repo(root.path());
1387 let session = format!("promote-symlink-{}", std::process::id());
1388 let binding = bind(&session, root.path(), true).await.unwrap();
1389 std::os::unix::fs::symlink("README.md", binding.worktree_path.join("evil-link")).unwrap();
1390
1391 let error = promote_current(&session).unwrap_err();
1392 let message = error.to_string();
1393 assert!(
1394 message.contains("non-regular file")
1395 || message.contains("symbolic link")
1396 || message.contains("refusing to promote"),
1397 "{message}"
1398 );
1399 discard(&session).await.unwrap();
1400 }
1401
1402 #[cfg(unix)]
1403 #[tokio::test]
1404 async fn write_promoted_file_releases_claim_when_destination_write_fails() {
1405 use std::os::unix::fs::PermissionsExt;
1406
1407 let root = tempfile::tempdir().unwrap();
1408 init_repo(root.path());
1409 let session = format!("promote-write-fail-{}", std::process::id());
1410 let binding = bind(&session, root.path(), true).await.unwrap();
1411 fs::write(binding.worktree_path.join("new-file.txt"), "payload\n").unwrap();
1412 let mut perms = fs::metadata(root.path()).unwrap().permissions();
1414 perms.set_mode(0o555);
1415 fs::set_permissions(root.path(), perms).unwrap();
1416
1417 let error = promote_current(&session).unwrap_err();
1418 let message = error.to_string();
1419 assert!(
1420 message.contains("failed to promote")
1421 || message.contains("failed to create")
1422 || message.contains("Permission denied")
1423 || message.contains("Read-only"),
1424 "{message}"
1425 );
1426
1427 let mut restore = fs::metadata(root.path()).unwrap().permissions();
1428 restore.set_mode(0o755);
1429 fs::set_permissions(root.path(), restore).unwrap();
1430 discard(&session).await.unwrap();
1431 }
1432
1433 #[test]
1434 fn refuse_symlink_components_rejects_dot_path_segments() {
1435 let root = tempfile::tempdir().unwrap();
1436 let error = refuse_symlink_components(root.path(), Path::new("./nested")).unwrap_err();
1437 assert!(error.to_string().contains("unsafe path"), "{error}");
1438 }
1439
1440 #[test]
1441 fn git_stdout_surfaces_stderr_when_git_fails() {
1442 let root = tempfile::tempdir().unwrap();
1443 init_repo(root.path());
1444 let error = git_stdout(root.path(), &["rev-parse", "missing-ref-xyz"]).unwrap_err();
1445 assert!(!error.to_string().is_empty(), "{error}");
1446 }
1447
1448 #[test]
1449 fn source_revision_fails_when_head_is_unborn() {
1450 let root = tempfile::tempdir().unwrap();
1451 fs::create_dir_all(root.path()).unwrap();
1452 git(root.path(), &["init"]);
1453 let error = source_revision(root.path()).unwrap_err();
1454 assert!(error.to_string().contains(ISOLATION_UNAVAILABLE), "{error}");
1455 }
1456
1457 #[tokio::test]
1458 async fn bind_reuses_an_existing_live_worktree_binding() {
1459 let root = tempfile::tempdir().unwrap();
1460 init_repo(root.path());
1461 let first = bind("reuse-binding", root.path(), true).await.unwrap();
1462 let second = bind_sync("reuse-binding", root.path(), true, true)
1463 .unwrap()
1464 .expect("existing binding");
1465 assert_eq!(first.worktree_path, second.worktree_path);
1466 assert_eq!(first.source_revision, second.source_revision);
1467 discard("reuse-binding").await.unwrap();
1468 }
1469
1470 #[tokio::test]
1471 async fn promote_current_overwrites_an_existing_tracked_file() {
1472 let root = tempfile::tempdir().unwrap();
1473 init_repo(root.path());
1474 fs::write(root.path().join("tracked.txt"), "old\n").unwrap();
1475 git(root.path(), &["add", "tracked.txt"]);
1476 git(root.path(), &["commit", "-m", "track"]);
1477 let binding = bind("promote-overwrite", root.path(), true).await.unwrap();
1478 fs::write(binding.worktree_path.join("tracked.txt"), "new\n").unwrap();
1479
1480 let outcome = promote_current("promote-overwrite").unwrap();
1481 assert!(matches!(outcome, PromoteOutcome::Applied { .. }));
1482 assert_eq!(
1483 fs::read_to_string(root.path().join("tracked.txt")).unwrap(),
1484 "new\n"
1485 );
1486 discard("promote-overwrite").await.unwrap();
1487 }
1488
1489 #[tokio::test]
1490 async fn bind_adopts_a_preexisting_worktree_directory() {
1491 let root = tempfile::tempdir().unwrap();
1492 init_repo(root.path());
1493 let session = "adopt-worktree";
1494 let worktree = worktree_path_for(root.path(), session);
1495 crate::git::create_worktree(
1496 root.path(),
1497 &format!("a3s-isolate-{session}"),
1498 &worktree,
1499 true,
1500 )
1501 .unwrap();
1502 assert!(worktree.join(".git").exists());
1503
1504 let binding = bind_sync(session, root.path(), true, true)
1505 .unwrap()
1506 .expect("adopted worktree");
1507 assert_eq!(binding.worktree_path, worktree);
1508 discard(session).await.unwrap();
1509 }
1510
1511 #[test]
1512 fn promote_fails_closed_without_a_session_binding() {
1513 let err = promote("never-bound-promote", "digest-x", "rev-1", |_| Ok(())).unwrap_err();
1514 assert!(err.to_string().contains("no binding"), "{err}");
1515 }
1516
1517 #[tokio::test]
1518 async fn promote_current_delete_is_idempotent_when_source_file_already_gone() {
1519 let root = tempfile::tempdir().unwrap();
1520 init_repo(root.path());
1521 fs::write(root.path().join("ephemeral.txt"), "tracked\n").unwrap();
1522 git(root.path(), &["add", "ephemeral.txt"]);
1523 git(root.path(), &["commit", "-m", "track ephemeral"]);
1524 let session = format!("promote-delete-gone-{}", std::process::id());
1525 let binding = bind(&session, root.path(), true).await.unwrap();
1526 fs::remove_file(binding.worktree_path.join("ephemeral.txt")).unwrap();
1527 fs::remove_file(root.path().join("ephemeral.txt")).unwrap();
1529
1530 let outcome = promote_current(&session).unwrap();
1531 assert!(matches!(outcome, PromoteOutcome::Applied { .. }));
1532 assert!(!root.path().join("ephemeral.txt").exists());
1533 discard(&session).await.unwrap();
1534 }
1535
1536 #[tokio::test]
1537 async fn bind_falls_through_when_existing_worktree_directory_is_gone() {
1538 let root = tempfile::tempdir().unwrap();
1539 init_repo(root.path());
1540 let session = format!("missing-worktree-{}", std::process::id());
1541 let branch = format!("a3s-isolate-{session}");
1542 let first = bind(&session, root.path(), true).await.unwrap();
1543 let _ = std::process::Command::new("git")
1546 .args([
1547 "worktree",
1548 "remove",
1549 "--force",
1550 first.worktree_path.to_str().unwrap(),
1551 ])
1552 .current_dir(root.path())
1553 .status();
1554 let _ = fs::remove_dir_all(&first.worktree_path);
1555 let _ = std::process::Command::new("git")
1556 .args(["worktree", "prune"])
1557 .current_dir(root.path())
1558 .status();
1559 let _ = std::process::Command::new("git")
1560 .args(["branch", "-D", &branch])
1561 .current_dir(root.path())
1562 .status();
1563 let second = bind_sync(&session, root.path(), true, true)
1564 .unwrap()
1565 .expect("recreate after missing worktree");
1566 assert!(second.worktree_path.exists());
1567 discard(&session).await.unwrap();
1568 }
1569
1570 #[tokio::test]
1571 async fn bind_fails_closed_when_stale_isolation_path_cannot_be_cleared() {
1572 let root = tempfile::tempdir().unwrap();
1573 init_repo(root.path());
1574 let session = format!("stale-file-{}", std::process::id());
1575 let stale = worktree_path_for(root.path(), &session);
1576 fs::write(&stale, "not-a-directory").unwrap();
1578 let error = bind_sync(&session, root.path(), true, true).unwrap_err();
1579 assert!(
1580 error.to_string().contains("could not be cleared"),
1581 "{error}"
1582 );
1583 let _ = fs::remove_file(&stale);
1584 }
1585
1586 #[cfg(unix)]
1587 #[tokio::test]
1588 async fn promote_delete_releases_claim_when_source_file_cannot_be_removed() {
1589 use std::os::unix::fs::PermissionsExt;
1590
1591 let root = tempfile::tempdir().unwrap();
1592 init_repo(root.path());
1593 fs::write(root.path().join("locked.txt"), "tracked\n").unwrap();
1594 git(root.path(), &["add", "locked.txt"]);
1595 git(root.path(), &["commit", "-m", "track locked"]);
1596 let session = format!("promote-delete-fail-{}", std::process::id());
1597 let binding = bind(&session, root.path(), true).await.unwrap();
1598 fs::remove_file(binding.worktree_path.join("locked.txt")).unwrap();
1599 let mut perms = fs::metadata(root.path()).unwrap().permissions();
1600 perms.set_mode(0o555);
1601 fs::set_permissions(root.path(), perms).unwrap();
1602
1603 let error = promote_current(&session).unwrap_err();
1604 let message = error.to_string();
1605 assert!(
1606 message.contains("failed to remove") || message.contains("Permission denied"),
1607 "{message}"
1608 );
1609
1610 let mut restore = fs::metadata(root.path()).unwrap().permissions();
1611 restore.set_mode(0o755);
1612 fs::set_permissions(root.path(), restore).unwrap();
1613 discard(&session).await.unwrap();
1614 }
1615
1616 #[cfg(unix)]
1617 #[tokio::test]
1618 async fn promote_refuses_a_typechanged_symlink_as_non_regular() {
1619 let root = tempfile::tempdir().unwrap();
1620 init_repo(root.path());
1621 fs::write(root.path().join("typed.txt"), "file\n").unwrap();
1622 git(root.path(), &["add", "typed.txt"]);
1623 git(root.path(), &["commit", "-m", "track typed"]);
1624 let session = format!("promote-typed-link-{}", std::process::id());
1625 let binding = bind(&session, root.path(), true).await.unwrap();
1626 fs::remove_file(binding.worktree_path.join("typed.txt")).unwrap();
1629 std::os::unix::fs::symlink("README.md", binding.worktree_path.join("typed.txt")).unwrap();
1630 let error = promote_current(&session).unwrap_err();
1631 assert!(
1632 error.to_string().contains("non-regular file")
1633 || error.to_string().contains("symbolic link")
1634 || error.to_string().contains("refusing to promote"),
1635 "{error}"
1636 );
1637 discard(&session).await.unwrap();
1638 }
1639
1640 #[test]
1641 fn git_stdout_reports_spawn_failure_when_git_is_missing_from_path() {
1642 struct RestorePath(Option<std::ffi::OsString>);
1643 impl Drop for RestorePath {
1644 fn drop(&mut self) {
1645 match self.0.take() {
1646 Some(value) => std::env::set_var("PATH", value),
1647 None => std::env::remove_var("PATH"),
1648 }
1649 }
1650 }
1651
1652 let root = tempfile::tempdir().unwrap();
1653 init_repo(root.path());
1654 let _guard = super::git_process_lock()
1655 .lock()
1656 .unwrap_or_else(|error| error.into_inner());
1657 let _restore = RestorePath(std::env::var_os("PATH"));
1658 std::env::set_var("PATH", "/var/empty-a3s-no-git");
1659 let error = super::git_stdout_unlocked(root.path(), &["status"]).unwrap_err();
1660 drop(_restore);
1661 drop(_guard);
1662 assert!(
1663 error.to_string().contains("git") || error.to_string().contains("failed"),
1664 "{error}"
1665 );
1666 }
1667
1668 #[tokio::test]
1669 async fn promote_current_handles_delete_then_recreate_of_tracked_path() {
1670 let root = tempfile::tempdir().unwrap();
1671 init_repo(root.path());
1672 fs::write(root.path().join("swap.txt"), "tracked\n").unwrap();
1673 git(root.path(), &["add", "swap.txt"]);
1674 git(root.path(), &["commit", "-m", "track swap"]);
1675 let session = format!("promote-swap-{}", std::process::id());
1676 let binding = bind(&session, root.path(), true).await.unwrap();
1677 fs::remove_file(binding.worktree_path.join("swap.txt")).unwrap();
1678 fs::write(binding.worktree_path.join("swap.txt"), "replacement\n").unwrap();
1679 let outcome = promote_current(&session).unwrap();
1680 assert!(matches!(outcome, PromoteOutcome::Applied { .. }));
1681 assert_eq!(
1682 fs::read_to_string(root.path().join("swap.txt")).unwrap(),
1683 "replacement\n"
1684 );
1685 discard(&session).await.unwrap();
1686 }
1687
1688 #[cfg(unix)]
1689 #[tokio::test]
1690 async fn discard_rebinds_when_worktree_removal_fails_hard() {
1691 use std::os::unix::fs::PermissionsExt;
1692
1693 let outer = tempfile::tempdir().unwrap();
1694 let source = outer.path().join("repo");
1695 init_repo(&source);
1696 let session = format!("discard-rebind-{}", std::process::id());
1697 let binding = bind(&session, &source, true).await.unwrap();
1698 let parent = outer.path();
1701 let mut perms = fs::metadata(parent).unwrap().permissions();
1702 perms.set_mode(0o555);
1703 fs::set_permissions(parent, perms).unwrap();
1704
1705 let error = discard(&session).await;
1706 let mut restore = fs::metadata(parent).unwrap().permissions();
1707 restore.set_mode(0o755);
1708 fs::set_permissions(parent, restore).unwrap();
1709
1710 match error {
1711 Err(error) => {
1712 assert!(
1713 binding_exists(&session)
1714 || error.to_string().contains("isolation")
1715 || error.to_string().contains("Permission")
1716 || error.to_string().contains("not permitted"),
1717 "{error}"
1718 );
1719 let _ = discard(&session).await;
1720 }
1721 Ok(()) => {
1722 let _ = binding;
1723 }
1724 }
1725 }
1726
1727 fn binding_exists(session_id: &str) -> bool {
1728 state()
1729 .lock()
1730 .expect("isolation state")
1731 .bindings
1732 .contains_key(session_id)
1733 }
1734}