Skip to main content

a3s_code_core/
effect_isolation.rs

1//! Conversation writes land in a resettable git worktree.
2//!
3//! Isolation uses [`WorkspaceGitWorktreeProvider`]. A non-git root fails
4//! closed. Discard does not touch the source tree. Promote applies one
5//! change-set digest and refuses a moved source revision before any apply.
6
7use 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
53/// Read-only sessions bind the source tree and do not create a worktree.
54pub fn skip_for_read_only(can_write: bool) -> bool {
55    !can_write
56}
57
58/// Sync bind used by session construction. Uses the same git worktree helpers
59/// as [`LocalWorkspaceBackend`].
60pub 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    // Sibling paths are keyed only by session id under the source parent. A
109    // crashed prior run can leave an empty `.a3s-isolate-*` directory that is
110    // not a git worktree; refuse to adopt it and clear the orphan so bind can
111    // recreate. Do not delete a path that still has `.git` metadata.
112    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
141/// Open an isolated worktree, or fail closed without writing `source_root`.
142pub 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
151/// Digest of the isolated change set, without applying it.
152///
153/// `Ok(None)` means the worktree has nothing to promote. A missing binding
154/// is an error.
155pub 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
165/// Promote the isolated worktree onto its source tree.
166///
167/// The change-set digest covers the exact patch and untracked files. A source
168/// revision that moved returns [`PromoteOutcome::Conflict`] before that patch
169/// is applied. Replaying the same digest is idempotent and does not apply again.
170pub 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, &current, 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
365/// Refuse a path another session already owns, then hold the claim only if
366/// the following mutation lands.
367fn 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(&current) {
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    // `is_absolute()` is not enough. On Windows `/tmp/escape` is not absolute,
462    // but a root or drive prefix is still not a workspace-relative path.
463    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
484/// Remove the conversation worktree. Does not delete source files.
485///
486/// Idempotent when the worktree directory is already gone or Git no longer
487/// lists it as a worktree (for example after an earlier cleanup).
488pub 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                // Re-bind so a later discard/retry can still find the session.
511                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
525/// Apply one change-set. A moved source revision returns before `apply`.
526pub 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    // The digest is the applied change-set identity. A later session must not
540    // apply it again just because the first marker was recorded under another id.
541    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
601/// Sibling path an isolated session uses for its worktree.
602///
603/// Hosts that clean up after a session must use this path. The session id is
604/// not trusted as a relative path; bind still refuses a retarget onto the source.
605pub 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    /// Hermetic leak check (formerly a 100-cycle soak). Two bind/discard
695    /// cycles cover the same assertions without inflating CI time or leaving
696    /// ignored bodies in the F-table denominator under `--no-cfg-coverage`.
697    #[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        // Pretend a previous process left a worktree directory with git metadata.
770        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        // Simulate an external cleanup that removed the Git worktree registration
798        // while leaving the binding in process state (live E2E teardown class).
799        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", &current, |_| {
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        // Freeze the source tree so the promote write cannot create the file.
1413        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        // Source already matches the delete; apply must still succeed.
1528        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        // Force-remove the worktree registration and its branch so recreate
1544        // can allocate the same session id again.
1545        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        // A file at the isolation path cannot be removed by remove_dir_all.
1577        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        // Replace the tracked regular file with a symlink so git reports a
1627        // typechange ('T') and read_promoted_file refuses non-regular metadata.
1628        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        // Freeze only our outer tempdir (worktree sibling parent), not the
1699        // process-wide TMPDIR.
1700        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}