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