1use std::borrow::Cow;
26use std::path::{Path, PathBuf};
27use std::process::Command;
28use std::sync::Arc;
29
30use crate::pack::{
31 Action, EnvArgs, EnvScope, ExecOnFail, ExecSpec, MkdirArgs, RequireOnFail, RequireSpec,
32 RmdirArgs, SymlinkArgs, SymlinkKind, UnlinkArgs, WhenSpec,
33};
34use crate::plugin::Registry;
35use crate::vars::{expand, VarEnv};
36
37use super::ctx::ExecCtx;
38use super::error::{io_to_fs, ExecError, EXEC_STDERR_CAPTURE_MAX};
39use super::predicate::{evaluate, evaluate_when_gate};
40use super::step::{
41 ExecResult, ExecStep, PredicateOutcome, StepKind, ACTION_ENV, ACTION_EXEC, ACTION_MKDIR,
42 ACTION_REQUIRE, ACTION_RMDIR, ACTION_SYMLINK, ACTION_UNLINK, ACTION_WHEN,
43};
44use super::ActionExecutor;
45
46#[derive(Debug, Clone)]
59pub struct FsExecutor {
60 registry: Arc<Registry>,
61}
62
63impl Default for FsExecutor {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl FsExecutor {
70 #[must_use]
74 pub fn new() -> Self {
75 Self { registry: Arc::new(Registry::bootstrap()) }
76 }
77
78 #[must_use]
86 pub fn with_registry(registry: Arc<Registry>) -> Self {
87 Self { registry }
88 }
89}
90
91impl ActionExecutor for FsExecutor {
92 fn name(&self) -> &'static str {
93 "fs"
94 }
95
96 fn execute(&self, action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
97 let name = action.name();
98 let plugin =
99 self.registry.get(name).ok_or_else(|| ExecError::UnknownAction(name.to_string()))?;
100 let nested_ctx = ExecCtx {
105 vars: ctx.vars,
106 pack_root: ctx.pack_root,
107 workspace: ctx.workspace,
108 platform: ctx.platform,
109 registry: Some(&self.registry),
110 pack_type_registry: ctx.pack_type_registry,
111 visited_meta: ctx.visited_meta,
112 scheduler: ctx.scheduler,
114 };
115 plugin.execute(action, &nested_ctx)
116 }
117}
118
119fn expand_field(raw: &str, env: &VarEnv, field: &'static str) -> Result<String, ExecError> {
122 expand(raw, env).map_err(|source| ExecError::VarExpand { field, source })
123}
124
125fn require_path(expanded: String) -> Result<PathBuf, ExecError> {
126 if expanded.is_empty() {
127 return Err(ExecError::InvalidPath(expanded));
128 }
129 Ok(PathBuf::from(expanded))
130}
131
132pub(crate) fn fs_symlink(args: &SymlinkArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
135 let src = require_path(expand_field(&args.src, ctx.vars, "symlink.src")?)?;
136 let dst = require_path(expand_field(&args.dst, ctx.vars, "symlink.dst")?)?;
137
138 let result = match classify_symlink_dst(&src, &dst) {
139 SymlinkState::AlreadyCorrect => ExecResult::AlreadySatisfied,
140 SymlinkState::Missing => {
141 create_symlink(&src, &dst, args.kind)?;
142 ExecResult::PerformedChange
143 }
144 SymlinkState::OccupiedByOther => {
145 if !args.backup {
146 return Err(ExecError::SymlinkDestOccupied { dst: dst.clone() });
147 }
148 let backup = backup_path(&dst)?;
153 match create_symlink(&src, &dst, args.kind) {
154 Ok(()) => ExecResult::PerformedChange,
155 Err(create_err) => {
156 return Err(rollback_or_orphan(&dst, &backup, create_err));
157 }
158 }
159 }
160 };
161
162 Ok(ExecStep {
163 action_name: Cow::Borrowed(ACTION_SYMLINK),
164 result,
165 details: StepKind::Symlink {
166 src,
167 dst,
168 kind: args.kind,
169 backup: args.backup,
170 normalize: args.normalize,
171 },
172 })
173}
174
175enum SymlinkState {
176 AlreadyCorrect,
177 Missing,
178 OccupiedByOther,
179}
180
181fn classify_symlink_dst(src: &Path, dst: &Path) -> SymlinkState {
182 match std::fs::symlink_metadata(dst) {
183 Err(_) => SymlinkState::Missing,
184 Ok(meta) if meta.file_type().is_symlink() => match std::fs::read_link(dst) {
185 Ok(target) if target == src => SymlinkState::AlreadyCorrect,
186 _ => SymlinkState::OccupiedByOther,
187 },
188 Ok(_) => SymlinkState::OccupiedByOther,
189 }
190}
191
192fn backup_path(dst: &Path) -> Result<PathBuf, ExecError> {
201 let mut backup = dst.as_os_str().to_owned();
202 backup.push(".grex.bak");
203 let backup = PathBuf::from(backup);
204 let _ = std::fs::remove_file(&backup);
207 let _ = std::fs::remove_dir_all(&backup);
208 std::fs::rename(dst, &backup).map_err(|e| io_to_fs("rename", dst.to_path_buf(), e))?;
209 Ok(backup)
210}
211
212fn rollback_or_orphan(dst: &Path, backup: &Path, create_err: ExecError) -> ExecError {
223 let create_detail = create_err.to_string();
224 match std::fs::rename(backup, dst) {
225 Ok(()) => {
226 create_err
229 }
230 Err(restore_err) => ExecError::SymlinkCreateAfterBackupFailed {
231 dst: dst.to_path_buf(),
232 backup: backup.to_path_buf(),
233 create_error: create_detail,
234 restore_error: Some(restore_err.to_string()),
235 },
236 }
237}
238
239#[cfg(unix)]
240fn create_symlink(src: &Path, dst: &Path, _kind: SymlinkKind) -> Result<(), ExecError> {
241 std::os::unix::fs::symlink(src, dst).map_err(|e| io_to_fs("symlink", dst.to_path_buf(), e))
242}
243
244#[cfg(windows)]
245fn create_symlink(src: &Path, dst: &Path, kind: SymlinkKind) -> Result<(), ExecError> {
246 let resolved = resolve_windows_symlink_kind(src, kind)?;
247 let result = match resolved {
248 SymlinkKind::Directory => std::os::windows::fs::symlink_dir(src, dst),
249 SymlinkKind::File | SymlinkKind::Auto => std::os::windows::fs::symlink_file(src, dst),
253 };
254 result.map_err(|e| map_windows_symlink_error(dst, e))
255}
256
257#[cfg(windows)]
266fn resolve_windows_symlink_kind(src: &Path, kind: SymlinkKind) -> Result<SymlinkKind, ExecError> {
267 match kind {
268 SymlinkKind::File | SymlinkKind::Directory => Ok(kind),
269 SymlinkKind::Auto => match std::fs::symlink_metadata(src) {
270 Ok(meta) if meta.file_type().is_dir() => Ok(SymlinkKind::Directory),
271 Ok(_) => Ok(SymlinkKind::File),
272 Err(e) => Err(ExecError::SymlinkAutoKindUnresolvable {
273 src: src.to_path_buf(),
274 detail: e.to_string(),
275 }),
276 },
277 }
278}
279
280#[cfg(windows)]
281fn map_windows_symlink_error(dst: &Path, err: std::io::Error) -> ExecError {
282 if err.raw_os_error() == Some(1314) {
284 return ExecError::SymlinkPrivilegeDenied { detail: err.to_string() };
285 }
286 io_to_fs("symlink", dst.to_path_buf(), err)
287}
288
289pub(crate) fn fs_unlink(args: &UnlinkArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
300 let dst = require_path(expand_field(&args.dst, ctx.vars, "unlink.dst")?)?;
301 let result = match std::fs::symlink_metadata(&dst) {
302 Ok(meta) if meta.file_type().is_symlink() => {
303 std::fs::remove_file(&dst).map_err(|e| io_to_fs("unlink", dst.clone(), e))?;
304 ExecResult::PerformedChange
305 }
306 _ => ExecResult::AlreadySatisfied,
307 };
308 Ok(ExecStep {
309 action_name: Cow::Borrowed(ACTION_UNLINK),
310 result,
311 details: StepKind::Unlink { dst },
312 })
313}
314
315pub(crate) fn fs_env(args: &EnvArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
318 let value = expand_field(&args.value, ctx.vars, "env.value")?;
319 apply_env(&args.name, &value, args.scope)?;
320 Ok(ExecStep {
321 action_name: Cow::Borrowed(ACTION_ENV),
322 result: ExecResult::PerformedChange,
323 details: StepKind::Env { name: args.name.clone(), value, scope: args.scope },
324 })
325}
326
327fn apply_env(name: &str, value: &str, scope: EnvScope) -> Result<(), ExecError> {
328 match scope {
329 EnvScope::Session => {
330 std::env::set_var(name, value);
334 Ok(())
335 }
336 EnvScope::User => apply_env_user(name, value),
337 EnvScope::Machine => apply_env_machine(name, value),
338 }
339}
340
341#[cfg(windows)]
342fn apply_env_user(name: &str, value: &str) -> Result<(), ExecError> {
343 use winreg::enums::{HKEY_CURRENT_USER, KEY_SET_VALUE};
344 use winreg::RegKey;
345 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
346 let env = hkcu.open_subkey_with_flags("Environment", KEY_SET_VALUE).map_err(|e| {
347 ExecError::EnvPersistenceDenied { scope: "user".to_string(), detail: e.to_string() }
348 })?;
349 env.set_value(name, &value.to_string()).map_err(|e| ExecError::EnvPersistenceDenied {
350 scope: "user".to_string(),
351 detail: e.to_string(),
352 })
353}
354
355#[cfg(not(windows))]
356fn apply_env_user(_name: &str, _value: &str) -> Result<(), ExecError> {
357 Err(ExecError::EnvPersistenceNotSupported {
358 scope: "user".to_string(),
359 platform: std::env::consts::OS,
360 })
361}
362
363#[cfg(windows)]
364fn apply_env_machine(name: &str, value: &str) -> Result<(), ExecError> {
365 use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_SET_VALUE};
366 use winreg::RegKey;
367 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
368 let env = hklm
369 .open_subkey_with_flags(
370 r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
371 KEY_SET_VALUE,
372 )
373 .map_err(|e| ExecError::EnvPersistenceDenied {
374 scope: "machine".to_string(),
375 detail: e.to_string(),
376 })?;
377 env.set_value(name, &value.to_string()).map_err(|e| ExecError::EnvPersistenceDenied {
378 scope: "machine".to_string(),
379 detail: e.to_string(),
380 })
381}
382
383#[cfg(not(windows))]
384fn apply_env_machine(_name: &str, _value: &str) -> Result<(), ExecError> {
385 Err(ExecError::EnvPersistenceNotSupported {
386 scope: "machine".to_string(),
387 platform: std::env::consts::OS,
388 })
389}
390
391pub(crate) fn fs_mkdir(args: &MkdirArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
394 let path = require_path(expand_field(&args.path, ctx.vars, "mkdir.path")?)?;
395 let result = apply_mkdir(&path, args.mode.as_deref())?;
396 Ok(ExecStep {
397 action_name: Cow::Borrowed(ACTION_MKDIR),
398 result,
399 details: StepKind::Mkdir { path, mode: args.mode.clone() },
400 })
401}
402
403fn apply_mkdir(path: &Path, mode: Option<&str>) -> Result<ExecResult, ExecError> {
404 match std::fs::symlink_metadata(path) {
405 Ok(meta) if meta.file_type().is_dir() => return Ok(ExecResult::AlreadySatisfied),
406 Ok(_) => {
407 return Err(ExecError::PathConflict {
408 path: path.to_path_buf(),
409 reason: "exists as file",
410 });
411 }
412 Err(_) => {}
413 }
414 std::fs::create_dir_all(path).map_err(|e| io_to_fs("create_dir", path.to_path_buf(), e))?;
415 apply_mode(path, mode)?;
416 Ok(ExecResult::PerformedChange)
417}
418
419#[cfg(unix)]
420fn apply_mode(path: &Path, mode: Option<&str>) -> Result<(), ExecError> {
421 use std::os::unix::fs::PermissionsExt;
422 let Some(mode) = mode else { return Ok(()) };
423 let Ok(bits) = u32::from_str_radix(mode, 8) else {
424 return Err(ExecError::InvalidPath(format!("invalid POSIX mode `{mode}`")));
425 };
426 std::fs::set_permissions(path, std::fs::Permissions::from_mode(bits))
427 .map_err(|e| io_to_fs("set_permissions", path.to_path_buf(), e))
428}
429
430#[cfg(windows)]
433fn apply_mode(_path: &Path, _mode: Option<&str>) -> Result<(), ExecError> {
434 Ok(())
435}
436
437pub(crate) fn fs_rmdir(args: &RmdirArgs, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
440 let path = require_path(expand_field(&args.path, ctx.vars, "rmdir.path")?)?;
441 let result = apply_rmdir(&path, args.backup, args.force)?;
442 Ok(ExecStep {
443 action_name: Cow::Borrowed(ACTION_RMDIR),
444 result,
445 details: StepKind::Rmdir { path, backup: args.backup, force: args.force },
446 })
447}
448
449fn apply_rmdir(path: &Path, backup: bool, force: bool) -> Result<ExecResult, ExecError> {
450 if !path.exists() {
451 return Ok(ExecResult::NoOp);
452 }
453 if backup {
454 backup_with_timestamp(path)?;
455 return Ok(ExecResult::PerformedChange);
456 }
457 let res = if force { std::fs::remove_dir_all(path) } else { std::fs::remove_dir(path) };
458 match res {
459 Ok(()) => Ok(ExecResult::PerformedChange),
460 Err(e) if !force && is_not_empty(&e) => {
461 Err(ExecError::RmdirNotEmpty { path: path.to_path_buf() })
462 }
463 Err(e) => Err(io_to_fs("remove_dir", path.to_path_buf(), e)),
464 }
465}
466
467fn is_not_empty(err: &std::io::Error) -> bool {
470 #[cfg(unix)]
471 {
472 matches!(err.raw_os_error(), Some(libc_enotempty) if libc_enotempty == 39 || libc_enotempty == 66)
473 }
474 #[cfg(windows)]
475 {
476 err.raw_os_error() == Some(145)
477 }
478 #[cfg(not(any(unix, windows)))]
479 {
480 let _ = err;
481 false
482 }
483}
484
485fn backup_with_timestamp(path: &Path) -> Result<(), ExecError> {
488 let ts = std::time::SystemTime::now()
489 .duration_since(std::time::UNIX_EPOCH)
490 .map(|d| d.as_nanos())
491 .unwrap_or(0);
492 let mut backup = path.as_os_str().to_owned();
493 backup.push(format!(".grex.bak.{ts}"));
494 let backup = PathBuf::from(backup);
495 std::fs::rename(path, &backup).map_err(|e| io_to_fs("rename", path.to_path_buf(), e))
496}
497
498pub(crate) fn fs_require(spec: &RequireSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
501 let satisfied = evaluate_combiner(&spec.combiner, ctx)?;
502 let outcome =
503 if satisfied { PredicateOutcome::Satisfied } else { PredicateOutcome::Unsatisfied };
504 let result = classify_require(satisfied, spec.on_fail)?;
505 Ok(ExecStep {
506 action_name: Cow::Borrowed(ACTION_REQUIRE),
507 result,
508 details: StepKind::Require { outcome, on_fail: spec.on_fail },
509 })
510}
511
512fn evaluate_combiner(
513 combiner: &crate::pack::Combiner,
514 ctx: &ExecCtx<'_>,
515) -> Result<bool, ExecError> {
516 use crate::pack::Combiner;
517 match combiner {
518 Combiner::AllOf(list) => {
519 for p in list {
520 if !evaluate(p, ctx)? {
521 return Ok(false);
522 }
523 }
524 Ok(true)
525 }
526 Combiner::AnyOf(list) => {
527 for p in list {
528 if evaluate(p, ctx)? {
529 return Ok(true);
530 }
531 }
532 Ok(false)
533 }
534 Combiner::NoneOf(list) => {
535 for p in list {
536 if evaluate(p, ctx)? {
537 return Ok(false);
538 }
539 }
540 Ok(true)
541 }
542 }
543}
544
545fn classify_require(satisfied: bool, on_fail: RequireOnFail) -> Result<ExecResult, ExecError> {
546 if satisfied {
547 return Ok(ExecResult::AlreadySatisfied);
548 }
549 match on_fail {
550 RequireOnFail::Error => {
551 Err(ExecError::RequireFailed { detail: "combiner evaluated to false".to_string() })
552 }
553 RequireOnFail::Skip => Ok(ExecResult::NoOp),
554 RequireOnFail::Warn => {
555 tracing::warn!(target: "grex::execute", "require predicate unsatisfied (on_fail=warn)");
556 Ok(ExecResult::NoOp)
557 }
558 }
559}
560
561pub(crate) fn fs_when(spec: &WhenSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
572 let branch_taken = evaluate_when_gate(spec, ctx)?;
573 let (result, nested_steps) = if branch_taken {
574 let mut out = Vec::with_capacity(spec.actions.len());
575 for a in &spec.actions {
576 out.push(dispatch_nested(a, ctx)?);
577 }
578 (ExecResult::PerformedChange, out)
579 } else {
580 (ExecResult::NoOp, Vec::new())
581 };
582 Ok(ExecStep {
583 action_name: Cow::Borrowed(ACTION_WHEN),
584 result,
585 details: StepKind::When { branch_taken, nested_steps },
586 })
587}
588
589fn dispatch_nested(action: &Action, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
593 let name = action.name();
594 match ctx.registry {
595 Some(reg) => {
596 let plugin = reg.get(name).ok_or_else(|| ExecError::UnknownAction(name.to_string()))?;
597 plugin.execute(action, ctx)
598 }
599 None => {
600 let fallback = FsExecutor::new();
601 fallback.execute(action, ctx)
602 }
603 }
604}
605
606pub(crate) fn fs_exec(spec: &ExecSpec, ctx: &ExecCtx<'_>) -> Result<ExecStep, ExecError> {
609 let cwd = match spec.cwd.as_deref() {
610 Some(s) => Some(require_path(expand_field(s, ctx.vars, "exec.cwd")?)?),
611 None => None,
612 };
613 let (cmdline, status, stderr) = spawn_exec(spec, cwd.as_deref(), ctx.vars)?;
614 let result = classify_exec(status, spec.on_fail, &cmdline, &stderr)?;
615 Ok(ExecStep {
616 action_name: Cow::Borrowed(ACTION_EXEC),
617 result,
618 details: StepKind::Exec { cmdline, cwd, on_fail: spec.on_fail, shell: spec.shell },
619 })
620}
621
622fn spawn_exec(
631 spec: &ExecSpec,
632 cwd: Option<&Path>,
633 vars: &VarEnv,
634) -> Result<(String, i32, String), ExecError> {
635 let (mut cmd, display) = build_command(spec, vars)?;
636 if let Some(dir) = cwd {
637 cmd.current_dir(dir);
638 }
639 if let Some(env_map) = &spec.env {
640 for (k, v) in env_map {
641 let expanded = expand_field(v, vars, "exec.env")?;
642 cmd.env(k, expanded);
643 }
644 }
645 let out = cmd.output().map_err(|e| ExecError::ExecSpawnFailed {
646 command: display.clone(),
647 detail: e.to_string(),
648 })?;
649 let code = out.status.code().unwrap_or(-1);
650 let stderr = truncate_stderr(&out.stderr);
651 Ok((display, code, stderr))
652}
653
654fn truncate_stderr(bytes: &[u8]) -> String {
661 if bytes.is_empty() {
662 return String::new();
663 }
664 let start = bytes.len().saturating_sub(EXEC_STDERR_CAPTURE_MAX);
665 String::from_utf8_lossy(&bytes[start..]).into_owned()
666}
667
668fn build_command(spec: &ExecSpec, vars: &VarEnv) -> Result<(Command, String), ExecError> {
669 match (spec.shell, &spec.cmd, &spec.cmd_shell) {
670 (false, Some(argv), None) => build_argv_command(argv, vars),
671 (true, None, Some(line)) => build_shell_command(line, vars),
672 _ => Err(ExecError::ExecInvalid(
673 "exec requires cmd (shell=false) XOR cmd_shell (shell=true)".to_string(),
674 )),
675 }
676}
677
678fn build_argv_command(argv: &[String], vars: &VarEnv) -> Result<(Command, String), ExecError> {
679 if argv.is_empty() {
680 return Err(ExecError::ExecInvalid("exec.cmd is empty".to_string()));
681 }
682 let mut expanded = Vec::with_capacity(argv.len());
683 for a in argv {
684 expanded.push(expand_field(a, vars, "exec.cmd")?);
685 }
686 let mut cmd = Command::new(&expanded[0]);
687 cmd.args(&expanded[1..]);
688 Ok((cmd, expanded.join(" ")))
689}
690
691fn build_shell_command(line: &str, vars: &VarEnv) -> Result<(Command, String), ExecError> {
692 let expanded = expand_field(line, vars, "exec.cmd_shell")?;
693 #[cfg(windows)]
694 let (program, flag) = ("cmd", "/C");
695 #[cfg(not(windows))]
696 let (program, flag) = ("sh", "-c");
697 let mut cmd = Command::new(program);
698 cmd.arg(flag).arg(&expanded);
699 Ok((cmd, expanded))
700}
701
702fn classify_exec(
703 status: i32,
704 on_fail: ExecOnFail,
705 cmdline: &str,
706 stderr: &str,
707) -> Result<ExecResult, ExecError> {
708 if status == 0 {
709 return Ok(ExecResult::PerformedChange);
710 }
711 match on_fail {
712 ExecOnFail::Error => Err(ExecError::ExecNonZero {
713 status,
714 command: cmdline.to_string(),
715 stderr: stderr.to_string(),
716 }),
717 ExecOnFail::Warn => {
718 tracing::warn!(
719 target: "grex::execute",
720 status,
721 command = %cmdline,
722 stderr = %stderr,
723 "exec returned non-zero (on_fail=warn)"
724 );
725 Ok(ExecResult::PerformedChange)
726 }
727 ExecOnFail::Ignore => Ok(ExecResult::NoOp),
728 }
729}