Skip to content

Commit 6218a95

Browse files
committed
tasks: support running in an elevated shell
1 parent cfc88c6 commit 6218a95

File tree

12 files changed

+130
-13
lines changed

12 files changed

+130
-13
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: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use clap::{Parser, Subcommand};
22
use devenv_tasks::{
3-
Config, RunMode, TaskConfig, TasksUi, VerbosityLevel, signal_handler::SignalHandler,
3+
Config, RunMode, SudoContent, TaskConfig, TasksUi, VerbosityLevel, signal_handler::SignalHandler,
44
};
5-
use std::env;
5+
use std::{env, fs, path::PathBuf};
66

77
#[derive(Parser)]
88
#[clap(author, version, about)]
@@ -19,6 +19,14 @@ enum Command {
1919

2020
#[clap(long, value_enum, default_value_t = RunMode::Single, help = "The execution mode for tasks (affects dependency resolution)")]
2121
mode: RunMode,
22+
23+
#[clap(
24+
long,
25+
value_parser,
26+
env = "DEVENV_TASK_FILE",
27+
help = "Path to a JSON file containing task definitions"
28+
)]
29+
task_file: Option<PathBuf>,
2230
},
2331
Export {
2432
#[clap()]
@@ -30,6 +38,14 @@ enum Command {
3038
async fn main() -> Result<(), Box<dyn std::error::Error>> {
3139
let args = Args::parse();
3240

41+
// Detect and handle sudo context FIRST, before any file operations
42+
let sudo_context = SudoContext::detect();
43+
if let Some(ref ctx) = sudo_context {
44+
// Drop privileges immediately so all files are created with correct ownership
45+
ctx.drop_privileges()
46+
.map_err(|e| format!("Failed to drop privileges: {}", e))?;
47+
}
48+
3349
// Determine verbosity level from DEVENV_CMDLINE
3450
let mut verbosity = if let Ok(cmdline) = env::var("DEVENV_CMDLINE") {
3551
let cmdline = cmdline.to_lowercase();
@@ -51,14 +67,39 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
5167
}
5268

5369
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)?;
70+
Command::Run {
71+
roots,
72+
mode,
73+
task_file,
74+
} => {
75+
let tasks: Vec<TaskConfig> = {
76+
let task_source = || {
77+
task_file
78+
.as_ref()
79+
.map(|p| format!("tasks file at {}", p.display()))
80+
.unwrap_or_else(|| "DEVENV_TASKS".to_string())
81+
};
5782

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

64105
// Create a global signal handler

devenv-tasks/src/privileges.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
/// # Important
34+
/// Order matters: we must set GID first, then UID, because once we drop
35+
/// UID privileges we can't change GID anymore.
36+
pub fn drop_privileges(&self) -> Result<(), nix::Error> {
37+
// Set group ID first
38+
setgid(self.gid)?;
39+
// Then set user ID
40+
setuid(self.uid)?;
41+
Ok(())
42+
}
43+
}

devenv-tasks/src/task_state.rs

Lines changed: 16 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,18 @@ 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+
sudo_cmd.args(["-E", cmd]);
84+
sudo_cmd
85+
} else {
86+
// Normal execution - no sudo involved
87+
Command::new(cmd)
88+
};
89+
7590
command.stdout(Stdio::piped()).stderr(Stdio::piped());
7691

7792
// 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ 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.env.DEVENV_DOTFILE}"
134+
"DEVENV_TASK_FILE=${config.env.DEVENV_TASK_FILE}"
135+
"-S"
136+
]);
137+
};
127138
processes = lib.mapAttrs
128139
(name: value:
129140
let

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 --tasks-file ${config.task.config} --mode all --devenv:processes:${name}")
156156
config.processes));
157157

158158
procfileEnv =

0 commit comments

Comments
 (0)