Skip to content

Commit 40742f9

Browse files
authored
Merge pull request #2175 from cachix/elevated-tasks
tasks: support running in an elevated shell
2 parents bd047cd + 5ef3b06 commit 40742f9

File tree

11 files changed

+133
-16
lines changed

11 files changed

+133
-16
lines changed

devenv-tasks/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ shell-escape.workspace = true
2525
glob = "0.3.3"
2626

2727
[target.'cfg(unix)'.dependencies]
28-
nix.workspace = true
28+
nix = { workspace = true, features = ["user"] }
2929

3030
[dev-dependencies]
3131
pretty_assertions.workspace = true

devenv-tasks/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use serde::{Deserialize, Serialize};
2+
use crate::SudoContext;
23

34
#[derive(Clone, Debug, Deserialize, Serialize)]
45
pub struct TaskConfig {
@@ -38,6 +39,8 @@ pub struct Config {
3839
pub tasks: Vec<TaskConfig>,
3940
pub roots: Vec<String>,
4041
pub run_mode: RunMode,
42+
#[serde(skip)]
43+
pub sudo_context: Option<SudoContext>,
4144
}
4245

4346
impl TryFrom<serde_json::Value> for Config {

devenv-tasks/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod config;
22
mod error;
3+
mod privileges;
34
pub mod signal_handler;
45
mod task_cache;
56
mod task_state;
@@ -9,6 +10,7 @@ pub mod ui;
910

1011
pub use config::{Config, RunMode, TaskConfig};
1112
pub use error::Error;
13+
pub use privileges::SudoContext;
1214
pub use tasks::{Tasks, TasksBuilder};
1315
pub use types::{Outputs, VerbosityLevel};
1416
pub use ui::{TasksStatus, TasksUi, TasksUiBuilder};

devenv-tasks/src/main.rs

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use clap::{Parser, Subcommand};
22
use devenv_tasks::{
3-
Config, RunMode, TaskConfig, TasksUi, VerbosityLevel, signal_handler::SignalHandler,
3+
Config, RunMode, SudoContext, TaskConfig, TasksUi, VerbosityLevel,
4+
signal_handler::SignalHandler,
45
};
5-
use std::env;
6+
use std::{env, fs, path::PathBuf};
67

78
#[derive(Parser)]
89
#[clap(author, version, about)]
@@ -19,6 +20,14 @@ enum Command {
1920

2021
#[clap(long, value_enum, default_value_t = RunMode::Single, help = "The execution mode for tasks (affects dependency resolution)")]
2122
mode: RunMode,
23+
24+
#[clap(
25+
long,
26+
value_parser,
27+
env = "DEVENV_TASK_FILE",
28+
help = "Path to a JSON file containing task definitions"
29+
)]
30+
task_file: Option<PathBuf>,
2231
},
2332
Export {
2433
#[clap()]
@@ -28,6 +37,14 @@ enum Command {
2837

2938
#[tokio::main]
3039
async fn main() -> Result<(), Box<dyn std::error::Error>> {
40+
// Detect and handle sudo.
41+
// Drop privileges immediately to avoid creating any files as root.
42+
let sudo_context = SudoContext::detect();
43+
if let Some(ref ctx) = sudo_context {
44+
ctx.drop_privileges()
45+
.map_err(|e| format!("Failed to drop privileges: {}", e))?;
46+
}
47+
3148
let args = Args::parse();
3249

3350
// Determine verbosity level from DEVENV_CMDLINE
@@ -46,19 +63,45 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
4663

4764
// Keeping backwards compatibility for existing scripts that might set DEVENV_TASKS_QUIET
4865
if let Ok(quiet_var) = env::var("DEVENV_TASKS_QUIET")
49-
&& (quiet_var == "true" || quiet_var == "1") {
50-
verbosity = VerbosityLevel::Quiet;
51-
}
66+
&& (quiet_var == "true" || quiet_var == "1")
67+
{
68+
verbosity = VerbosityLevel::Quiet;
69+
}
5270

5371
match args.command {
54-
Command::Run { roots, mode } => {
55-
let tasks_json = env::var("DEVENV_TASKS")?;
56-
let tasks: Vec<TaskConfig> = serde_json::from_str(&tasks_json)?;
72+
Command::Run {
73+
roots,
74+
mode,
75+
task_file,
76+
} => {
77+
let tasks: Vec<TaskConfig> = {
78+
let task_source = || {
79+
task_file
80+
.as_ref()
81+
.map(|p| format!("tasks file at {}", p.display()))
82+
.unwrap_or_else(|| "DEVENV_TASKS".to_string())
83+
};
5784

85+
let data = env::var("DEVENV_TASKS").or_else(|_| {
86+
task_file
87+
.as_ref()
88+
.ok_or_else(|| {
89+
"No task file specified and DEVENV_TASKS environment variable not set"
90+
.to_string()
91+
})
92+
.and_then(|path| {
93+
fs::read_to_string(path)
94+
.map_err(|e| format!("Failed to read {}: {e}", task_source()))
95+
})
96+
})?;
97+
serde_json::from_str(&data)
98+
.map_err(|e| format!("Failed to parse {} as JSON: {e}", task_source()))?
99+
};
58100
let config = Config {
59101
tasks,
60102
roots,
61103
run_mode: mode,
104+
sudo_context: sudo_context.clone(),
62105
};
63106

64107
// Create a global signal handler

devenv-tasks/src/privileges.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
use std::env;
2+
use nix::unistd::{Uid, Gid, setuid, setgid, geteuid};
3+
4+
/// Context information about the original user when running under sudo
5+
#[derive(Debug, Clone)]
6+
pub struct SudoContext {
7+
pub user: String,
8+
pub uid: Uid,
9+
pub gid: Gid,
10+
}
11+
12+
impl SudoContext {
13+
/// Detect if we're running under sudo and extract the original user context
14+
pub fn detect() -> Option<Self> {
15+
// Only if we're running as root AND have SUDO_USER set
16+
if !geteuid().is_root() {
17+
return None;
18+
}
19+
20+
let user = env::var("SUDO_USER").ok()?;
21+
let uid = env::var("SUDO_UID").ok()?.parse().ok()?;
22+
let gid = env::var("SUDO_GID").ok()?.parse().ok()?;
23+
24+
Some(SudoContext {
25+
user,
26+
uid: Uid::from_raw(uid),
27+
gid: Gid::from_raw(gid),
28+
})
29+
}
30+
31+
/// Drop privileges to the original user
32+
///
33+
/// Order matters: we must set GID first, then UID, because once we drop UID privileges we can't change GID anymore.
34+
pub fn drop_privileges(&self) -> Result<(), nix::Error> {
35+
setgid(self.gid)?;
36+
setuid(self.uid)?;
37+
Ok(())
38+
}
39+
}

devenv-tasks/src/task_state.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::config::TaskConfig;
22
use crate::task_cache::{TaskCache, expand_glob_patterns};
33
use crate::types::{Output, Skipped, TaskCompleted, TaskFailure, TaskStatus, VerbosityLevel};
4+
use crate::SudoContext;
45
use miette::{IntoDiagnostic, Result, WrapErr};
56
use std::collections::BTreeMap;
67
use std::process::Stdio;
@@ -18,19 +19,22 @@ pub struct TaskState {
1819
pub status: TaskStatus,
1920
pub verbosity: VerbosityLevel,
2021
pub cancellation_token: Option<CancellationToken>,
22+
pub sudo_context: Option<SudoContext>,
2123
}
2224

2325
impl TaskState {
2426
pub fn new(
2527
task: TaskConfig,
2628
verbosity: VerbosityLevel,
2729
cancellation_token: Option<CancellationToken>,
30+
sudo_context: Option<SudoContext>,
2831
) -> Self {
2932
Self {
3033
task,
3134
status: TaskStatus::Pending,
3235
verbosity,
3336
cancellation_token,
37+
sudo_context,
3438
}
3539
}
3640

@@ -71,7 +75,19 @@ impl TaskState {
7175
cmd: &str,
7276
outputs: &BTreeMap<String, serde_json::Value>,
7377
) -> Result<(Command, tempfile::NamedTempFile)> {
74-
let mut command = Command::new(cmd);
78+
// If we dropped privileges but have sudo context, restore sudo for the task
79+
let mut command = if let Some(_ctx) = &self.sudo_context {
80+
// Wrap with sudo to restore elevated privileges
81+
let mut sudo_cmd = Command::new("sudo");
82+
// Use -E to preserve environment variables
83+
// The command here is a store path to a task script, not an arbitrary shell command.
84+
sudo_cmd.args(["-E", cmd]);
85+
sudo_cmd
86+
} else {
87+
// Normal execution - no sudo involved
88+
Command::new(cmd)
89+
};
90+
7591
command.stdout(Stdio::piped()).stderr(Stdio::piped());
7692

7793
// Set working directory if specified

devenv-tasks/src/tasks.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ impl TasksBuilder {
9090
task,
9191
self.verbosity,
9292
self.cancellation_token.clone(),
93+
self.config.sudo_context.clone(),
9394
))));
9495
task_indices.insert(name, index);
9596
}

devenv/src/devenv.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@ impl Devenv {
789789
roots,
790790
tasks,
791791
run_mode,
792+
sudo_context: None,
792793
};
793794
debug!(
794795
"Tasks config: {}",

src/modules/process-managers/process-compose.nix

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,22 @@ in
124124
version = lib.mkDefault "0.5";
125125
is_strict = lib.mkDefault true;
126126
log_location = lib.mkDefault "${config.env.DEVENV_STATE}/process-compose/process-compose.log";
127+
shell = {
128+
shell_command = lib.mkDefault (lib.getExe pkgs.bashInteractive);
129+
shell_argument = lib.mkDefault "-c";
130+
elevated_shell_command = lib.mkDefault "sudo";
131+
# Pass-through environment variables required by devenv-tasks when using elevated processes.
132+
elevated_shell_argument = lib.mkDefault (lib.concatStringsSep " " [
133+
"DEVENV_DOTFILE='${config.devenv.dotfile}'"
134+
"DEVENV_CMDLINE=\"$DEVENV_CMDLINE\""
135+
"DEVENV_TASK_FILE='${config.env.DEVENV_TASK_FILE}'"
136+
"-S"
137+
]);
138+
};
127139
processes = lib.mapAttrs
128140
(name: value:
129141
let
130-
taskCmd = "${config.task.package}/bin/devenv-tasks run --mode all devenv:processes:${name}";
142+
taskCmd = "${config.task.package}/bin/devenv-tasks run --task-file ${config.task.config} --mode all devenv:processes:${name}";
131143
command =
132144
if value.process-compose.is_elevated or false
133145
then taskCmd

src/modules/processes.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ in
152152

153153
procfile =
154154
pkgs.writeText "procfile" (lib.concatStringsSep "\n"
155-
(lib.mapAttrsToList (name: process: "${name}: exec ${config.task.package}/bin/devenv-tasks run --mode all devenv:processes:${name}")
155+
(lib.mapAttrsToList (name: process: "${name}: exec ${config.task.package}/bin/devenv-tasks run --task-file ${config.task.config} --mode all --devenv:processes:${name}")
156156
config.processes));
157157

158158
procfileEnv =

0 commit comments

Comments
 (0)