Skip to main content

agent_runtime/install/
executor.rs

1//! Apply executor for the install plan. Walks each [`PlanAction`] in
2//! order, mutating the filesystem only when the current state diverges
3//! from the desired state. Second-run-is-a-no-op idempotence is the
4//! load-bearing invariant Plan 04 Sprint 1 Task 1.2 ships against — see
5//! the integration test in `tests/integration/install_pipeline.rs`.
6
7use super::plan::{InstallPlan, PlanAction, SymlinkLinkMode};
8use crate::managed_block::{CommentStyle as MbStyle, ManagedBlock};
9use std::fs;
10use std::io;
11use std::os::unix::fs as unix_fs;
12use std::path::{Path, PathBuf};
13use std::time::SystemTime;
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum ApplyError {
18    #[error("io error at {path}: {source}")]
19    Io {
20        path: PathBuf,
21        #[source]
22        source: io::Error,
23    },
24    #[error("could not back up {dest} to {backup}: {source}")]
25    Backup {
26        dest: PathBuf,
27        backup: PathBuf,
28        #[source]
29        source: io::Error,
30    },
31    #[error("managed-block helper rejected entry `{entry_id}` for {config_file}: {source}")]
32    ManagedBlock {
33        entry_id: String,
34        config_file: PathBuf,
35        #[source]
36        source: crate::managed_block::ManagedBlockError,
37    },
38    #[error(
39        "tag `{value}` is not a trusted tag name (allowed: ASCII alphanumeric / `-` / `_`, non-empty)"
40    )]
41    InvalidTag { value: String },
42}
43
44/// Tag-name trust contract: non-empty ASCII alphanumeric / `-` / `_`.
45/// Mirrors `crate::managed_block::is_trusted_surface` because both produce
46/// filesystem-visible names from user-controlled identifiers. Validated at
47/// the executor entry as defense in depth — the CLI also rejects bad tags,
48/// but library callers using `InstallOptions { tag, .. }` directly hit the
49/// same gate.
50pub fn is_trusted_tag(s: &str) -> bool {
51    !s.is_empty()
52        && s.chars()
53            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
54}
55
56/// Single applied change. The dry-run printer also emits a list of these
57/// (without running them) so the user sees exactly what `--apply` would
58/// do.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum AppliedChange {
61    SymlinkCreated {
62        entry_id: String,
63        dest: PathBuf,
64        source: PathBuf,
65        link_mode: SymlinkLinkMode,
66    },
67    SymlinkReplaced {
68        entry_id: String,
69        dest: PathBuf,
70        source: PathBuf,
71        link_mode: SymlinkLinkMode,
72    },
73    FileBackedUpThenSymlinked {
74        entry_id: String,
75        dest: PathBuf,
76        source: PathBuf,
77        link_mode: SymlinkLinkMode,
78        backup: PathBuf,
79    },
80    ManagedBlockApplied {
81        entry_id: String,
82        config_file: PathBuf,
83    },
84    NoOp {
85        entry_id: String,
86        dest: PathBuf,
87    },
88}
89
90#[derive(Debug, Clone, Copy)]
91pub enum Mode {
92    DryRun,
93    Apply,
94}
95
96/// Walk `plan.actions` and return the change set. In [`Mode::DryRun`]
97/// the filesystem is untouched. In [`Mode::Apply`] every divergence is
98/// reconciled. `now` is injected so the backup-directory timestamp is
99/// deterministic in tests. When `tag` is set and at least one backup
100/// directory was created during apply, a `tag-<name>` marker file is
101/// written at the backup-run root so `gc-backups` (Task 2.4) can
102/// preserve it across retention sweeps.
103pub fn run(
104    plan: &InstallPlan,
105    mode: Mode,
106    now: SystemTime,
107    tag: Option<&str>,
108) -> Result<Vec<AppliedChange>, ApplyError> {
109    if let Some(name) = tag
110        && !is_trusted_tag(name)
111    {
112        return Err(ApplyError::InvalidTag {
113            value: name.to_string(),
114        });
115    }
116    let backup_root = backup_root_for(plan, now);
117    let mut changes = Vec::with_capacity(plan.actions.len());
118    for action in &plan.actions {
119        let change = match action {
120            PlanAction::Symlink {
121                entry_id,
122                source,
123                dest,
124                link_mode,
125                requires_backup,
126            } => handle_symlink(
127                mode,
128                entry_id,
129                source,
130                dest,
131                *link_mode,
132                *requires_backup,
133                &backup_root,
134            )?,
135            PlanAction::ManagedBlock {
136                entry_id,
137                config_file,
138                surface,
139                comment_style,
140                body,
141            } => handle_managed_block(mode, entry_id, config_file, surface, *comment_style, body)?,
142        };
143        changes.push(change);
144    }
145
146    // Write the tag marker only when we are in Apply mode AND at least one
147    // backup directory was created during this run. Dry-run never touches
148    // state_home; runs with zero backups produce no run root for the tag
149    // to live in.
150    if let (Mode::Apply, Some(name)) = (mode, tag) {
151        let had_backup = changes
152            .iter()
153            .any(|c| matches!(c, AppliedChange::FileBackedUpThenSymlinked { .. }));
154        if had_backup {
155            let marker = backup_root.join(format!("tag-{name}"));
156            fs::create_dir_all(&backup_root).map_err(|source| ApplyError::Io {
157                path: backup_root.clone(),
158                source,
159            })?;
160            fs::write(&marker, b"").map_err(|source| ApplyError::Io {
161                path: marker,
162                source,
163            })?;
164        }
165    }
166    Ok(changes)
167}
168
169fn backup_root_for(plan: &InstallPlan, now: SystemTime) -> PathBuf {
170    let secs = now
171        .duration_since(SystemTime::UNIX_EPOCH)
172        .map(|d| d.as_secs())
173        .unwrap_or(0);
174    plan.state_home
175        .join("backups")
176        .join(&plan.product)
177        .join(format!("{secs}"))
178}
179
180fn handle_symlink(
181    mode: Mode,
182    entry_id: &str,
183    source: &Path,
184    dest: &Path,
185    link_mode: SymlinkLinkMode,
186    requires_backup_flag: bool,
187    backup_root: &Path,
188) -> Result<AppliedChange, ApplyError> {
189    let current = read_symlink_target(dest);
190    if current.as_deref() == Some(source) {
191        // Already a symlink pointing at our source. Idempotent no-op.
192        return Ok(AppliedChange::NoOp {
193            entry_id: entry_id.to_string(),
194            dest: dest.to_path_buf(),
195        });
196    }
197
198    if matches!(mode, Mode::DryRun) {
199        return Ok(classify_dry_run(
200            entry_id,
201            source,
202            dest,
203            link_mode,
204            requires_backup_flag,
205            backup_root,
206        ));
207    }
208
209    ensure_parent_dir(dest)?;
210
211    let meta = fs::symlink_metadata(dest);
212    match meta {
213        Ok(m) if m.file_type().is_symlink() => {
214            // Existing symlink pointing somewhere else — replace.
215            fs::remove_file(dest).map_err(|source| ApplyError::Io {
216                path: dest.to_path_buf(),
217                source,
218            })?;
219            unix_fs::symlink(source, dest).map_err(|source_err| ApplyError::Io {
220                path: dest.to_path_buf(),
221                source: source_err,
222            })?;
223            Ok(AppliedChange::SymlinkReplaced {
224                entry_id: entry_id.to_string(),
225                dest: dest.to_path_buf(),
226                source: source.to_path_buf(),
227                link_mode,
228            })
229        }
230        Ok(m) if m.file_type().is_file() => {
231            // Existing regular file — back it up, then symlink.
232            let backup = move_to_backup(dest, entry_id, backup_root)?;
233            unix_fs::symlink(source, dest).map_err(|source_err| ApplyError::Io {
234                path: dest.to_path_buf(),
235                source: source_err,
236            })?;
237            Ok(AppliedChange::FileBackedUpThenSymlinked {
238                entry_id: entry_id.to_string(),
239                dest: dest.to_path_buf(),
240                source: source.to_path_buf(),
241                link_mode,
242                backup,
243            })
244        }
245        Ok(m) => {
246            // Directory or other file type at `dest`. Refuse — we don't
247            // own destruction of directories.
248            Err(ApplyError::Io {
249                path: dest.to_path_buf(),
250                source: io::Error::new(
251                    io::ErrorKind::AlreadyExists,
252                    format!(
253                        "refusing to overwrite non-file destination (file_type={:?})",
254                        m.file_type()
255                    ),
256                ),
257            })
258        }
259        Err(e) if e.kind() == io::ErrorKind::NotFound => {
260            unix_fs::symlink(source, dest).map_err(|source_err| ApplyError::Io {
261                path: dest.to_path_buf(),
262                source: source_err,
263            })?;
264            Ok(AppliedChange::SymlinkCreated {
265                entry_id: entry_id.to_string(),
266                dest: dest.to_path_buf(),
267                source: source.to_path_buf(),
268                link_mode,
269            })
270        }
271        Err(e) => Err(ApplyError::Io {
272            path: dest.to_path_buf(),
273            source: e,
274        }),
275    }
276}
277
278fn classify_dry_run(
279    entry_id: &str,
280    source: &Path,
281    dest: &Path,
282    link_mode: SymlinkLinkMode,
283    requires_backup_flag: bool,
284    backup_root: &Path,
285) -> AppliedChange {
286    let meta = fs::symlink_metadata(dest);
287    match meta {
288        Ok(m) if m.file_type().is_symlink() => AppliedChange::SymlinkReplaced {
289            entry_id: entry_id.to_string(),
290            dest: dest.to_path_buf(),
291            source: source.to_path_buf(),
292            link_mode,
293        },
294        Ok(m) if m.file_type().is_file() => {
295            let backup = backup_root
296                .join(entry_id)
297                .join(dest.file_name().map(Path::new).unwrap_or(Path::new("file")));
298            AppliedChange::FileBackedUpThenSymlinked {
299                entry_id: entry_id.to_string(),
300                dest: dest.to_path_buf(),
301                source: source.to_path_buf(),
302                link_mode,
303                backup,
304            }
305        }
306        Ok(_) => AppliedChange::SymlinkReplaced {
307            entry_id: entry_id.to_string(),
308            dest: dest.to_path_buf(),
309            source: source.to_path_buf(),
310            link_mode,
311        },
312        Err(_) if requires_backup_flag => {
313            // Plan said "requires backup" but the file vanished between
314            // plan time and dry-run time — fall back to plain create.
315            AppliedChange::SymlinkCreated {
316                entry_id: entry_id.to_string(),
317                dest: dest.to_path_buf(),
318                source: source.to_path_buf(),
319                link_mode,
320            }
321        }
322        Err(_) => AppliedChange::SymlinkCreated {
323            entry_id: entry_id.to_string(),
324            dest: dest.to_path_buf(),
325            source: source.to_path_buf(),
326            link_mode,
327        },
328    }
329}
330
331fn move_to_backup(dest: &Path, entry_id: &str, backup_root: &Path) -> Result<PathBuf, ApplyError> {
332    let file_name = dest.file_name().map(Path::new).unwrap_or(Path::new("file"));
333    let backup_dir = backup_root.join(entry_id);
334    fs::create_dir_all(&backup_dir).map_err(|source| ApplyError::Backup {
335        dest: dest.to_path_buf(),
336        backup: backup_dir.clone(),
337        source,
338    })?;
339    let backup_path = backup_dir.join(file_name);
340    fs::rename(dest, &backup_path).map_err(|source| ApplyError::Backup {
341        dest: dest.to_path_buf(),
342        backup: backup_path.clone(),
343        source,
344    })?;
345    Ok(backup_path)
346}
347
348fn ensure_parent_dir(dest: &Path) -> Result<(), ApplyError> {
349    if let Some(parent) = dest.parent() {
350        fs::create_dir_all(parent).map_err(|source| ApplyError::Io {
351            path: parent.to_path_buf(),
352            source,
353        })?;
354    }
355    Ok(())
356}
357
358fn read_symlink_target(p: &Path) -> Option<PathBuf> {
359    fs::read_link(p).ok()
360}
361
362fn handle_managed_block(
363    mode: Mode,
364    entry_id: &str,
365    config_file: &Path,
366    surface: &str,
367    comment_style: super::link_map::CommentStyle,
368    body: &str,
369) -> Result<AppliedChange, ApplyError> {
370    let helper_style = match comment_style {
371        super::link_map::CommentStyle::Hash => MbStyle::Hash,
372        super::link_map::CommentStyle::DoubleSlash => MbStyle::DoubleSlash,
373    };
374    let block = ManagedBlock::new(surface.to_string(), helper_style);
375
376    let existing = match fs::read_to_string(config_file) {
377        Ok(s) => s,
378        Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
379        Err(e) => {
380            return Err(ApplyError::Io {
381                path: config_file.to_path_buf(),
382                source: e,
383            });
384        }
385    };
386
387    // First install requires `force`; subsequent writes do not.
388    let needs_force = block.read(&existing).map(|o| o.is_none()).unwrap_or(true);
389
390    if matches!(mode, Mode::DryRun) {
391        let projected = block
392            .write(&existing, body, needs_force)
393            .map_err(|source| ApplyError::ManagedBlock {
394                entry_id: entry_id.to_string(),
395                config_file: config_file.to_path_buf(),
396                source,
397            })?;
398        return Ok(if projected == existing {
399            AppliedChange::NoOp {
400                entry_id: entry_id.to_string(),
401                dest: config_file.to_path_buf(),
402            }
403        } else {
404            AppliedChange::ManagedBlockApplied {
405                entry_id: entry_id.to_string(),
406                config_file: config_file.to_path_buf(),
407            }
408        });
409    }
410
411    let new_content = block
412        .write(&existing, body, needs_force)
413        .map_err(|source| ApplyError::ManagedBlock {
414            entry_id: entry_id.to_string(),
415            config_file: config_file.to_path_buf(),
416            source,
417        })?;
418    if new_content == existing {
419        return Ok(AppliedChange::NoOp {
420            entry_id: entry_id.to_string(),
421            dest: config_file.to_path_buf(),
422        });
423    }
424    ensure_parent_dir(config_file)?;
425    fs::write(config_file, new_content).map_err(|source| ApplyError::Io {
426        path: config_file.to_path_buf(),
427        source,
428    })?;
429    Ok(AppliedChange::ManagedBlockApplied {
430        entry_id: entry_id.to_string(),
431        config_file: config_file.to_path_buf(),
432    })
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::install::plan::{InstallPlan, SymlinkLinkMode};
439    use pretty_assertions::assert_eq;
440    use std::fs;
441    use tempfile::TempDir;
442
443    #[test]
444    fn trusted_tag_accepts_alnum_dash_underscore() {
445        assert!(is_trusted_tag("pre-bump"));
446        assert!(is_trusted_tag("rc_1"));
447        assert!(is_trusted_tag("0-2-0"));
448        assert!(is_trusted_tag("A1B2C3"));
449    }
450
451    #[test]
452    fn trusted_tag_rejects_empty_or_unsafe() {
453        assert!(!is_trusted_tag(""));
454        assert!(!is_trusted_tag("../escape"));
455        assert!(!is_trusted_tag("with space"));
456        assert!(!is_trusted_tag("dot.in.name"));
457        assert!(!is_trusted_tag("slash/in/name"));
458        assert!(!is_trusted_tag("null\0byte"));
459    }
460
461    #[test]
462    fn run_rejects_untrusted_tag_before_walking_actions() {
463        // Pins the defense-in-depth gate so a library caller that bypasses
464        // the CLI cannot compose `InstallOptions { tag: "../escape", .. }`
465        // that sneaks past the friendly anyhow message in commands::install.
466        let tmp = TempDir::new().unwrap();
467        let plan = InstallPlan {
468            product: "claude".to_string(),
469            source_root: tmp.path().to_path_buf(),
470            home: tmp.path().join("home"),
471            state_home: tmp.path().join("state"),
472            actions: Vec::new(),
473        };
474        let err = run(
475            &plan,
476            Mode::Apply,
477            SystemTime::UNIX_EPOCH,
478            Some("../escape"),
479        )
480        .unwrap_err();
481        match err {
482            ApplyError::InvalidTag { value } => assert_eq!(value, "../escape"),
483            other => panic!("expected InvalidTag, got {other:?}"),
484        }
485    }
486
487    fn directory_symlink_plan(tmp: &TempDir) -> (InstallPlan, PathBuf, PathBuf) {
488        let source = tmp.path().join("source/skills/reporting/daily-brief");
489        fs::create_dir_all(&source).unwrap();
490        fs::write(source.join("SKILL.md"), "# daily brief\n").unwrap();
491        let dest = tmp.path().join("home/skills/reporting/daily-brief");
492        let plan = InstallPlan {
493            product: "codex".to_string(),
494            source_root: tmp.path().join("source"),
495            home: tmp.path().join("home"),
496            state_home: tmp.path().join("state"),
497            actions: vec![PlanAction::Symlink {
498                entry_id: "reporting.daily-brief".to_string(),
499                source: source.clone(),
500                dest: dest.clone(),
501                link_mode: SymlinkLinkMode::Directory,
502                requires_backup: false,
503            }],
504        };
505        (plan, source, dest)
506    }
507
508    #[test]
509    fn apply_creates_directory_symlink_and_second_apply_is_noop() {
510        let tmp = TempDir::new().unwrap();
511        let (plan, source, dest) = directory_symlink_plan(&tmp);
512
513        let first = run(&plan, Mode::Apply, SystemTime::UNIX_EPOCH, None).unwrap();
514
515        assert_eq!(
516            first,
517            vec![AppliedChange::SymlinkCreated {
518                entry_id: "reporting.daily-brief".to_string(),
519                dest: dest.clone(),
520                source: source.clone(),
521                link_mode: SymlinkLinkMode::Directory,
522            }]
523        );
524        assert!(
525            fs::symlink_metadata(&dest)
526                .unwrap()
527                .file_type()
528                .is_symlink()
529        );
530        assert_eq!(fs::read_link(&dest).unwrap(), source);
531
532        let second = run(&plan, Mode::Apply, SystemTime::UNIX_EPOCH, None).unwrap();
533
534        assert_eq!(
535            second,
536            vec![AppliedChange::NoOp {
537                entry_id: "reporting.daily-brief".to_string(),
538                dest,
539            }]
540        );
541    }
542
543    #[test]
544    fn apply_refuses_to_overwrite_existing_real_directory() {
545        let tmp = TempDir::new().unwrap();
546        let (plan, _source, dest) = directory_symlink_plan(&tmp);
547        fs::create_dir_all(&dest).unwrap();
548
549        let err = run(&plan, Mode::Apply, SystemTime::UNIX_EPOCH, None).unwrap_err();
550
551        match err {
552            ApplyError::Io { path, source } => {
553                assert_eq!(path, dest);
554                assert_eq!(source.kind(), io::ErrorKind::AlreadyExists);
555            }
556            other => panic!("expected AlreadyExists io error, got {other:?}"),
557        }
558    }
559}