You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
bootler's session helper is a root process inside a systemd transient service. It applies a pinned execution profile to every command it spawns: exactly PATH, HOME, USER, LOGNAME, LANG and LC_ALL, and the admitted working directory (/ for run), with no TTY (aicers/bootler#416 Context and Scope; aicers/bootler#418 Scope item 3). Root children are bootler's own. Two identities need this crate:
Service(account). The profile must be applied aftersudo -u identity selection, because sudo's env_reset discards an outer environment (aicers/bootler#416 "Identity selection comes first").
Operator. The command must run as the uid and gid the session handshake authenticated, never as root (aicers/bootler#418 Scope item 3).
bootler must not re-implement sudo -u descent, the sentinel script, elevation classification or any uid/gid switch (aicers/bootler#416, #418 Constraints). Its helper obtains a std::process::Command, sets stdio and process_group(P) and spawns it itself, so the identity-selecting process and its descendants stay in its anchored group P; a hook that cannot yield such a Command blocks aicers/bootler#418 (Scope item 3).
InDaemonExecutor can do neither: its resolution is private, it applies no environment or directory, and it refuses Identity::Operator with NoOperatorIdentity. run_with_input spawns internally, in its own process group, with an always-empty environment.
Scope
Add to src/executor.rs (all public items with rustdoc and # Errors; these names are decided, private helpers are the implementer's):
/// Who a root process descends to. Not an `Identity`.#[derive(Debug,Clone,Copy,PartialEq,Eq)]pubenumDescent{Service(ServiceAccount),Operator(OperatorIds),}/// An already-authenticated operator's numeric ids. Fields private.#[derive(Debug,Clone,Copy,PartialEq,Eq)]pubstructOperatorIds{/* uid: u32, gid: u32 */}implOperatorIds{pubfnnew(uid:u32,gid:u32) -> Result<Self,DescentError>;pubfnuid(self) -> u32;pubfngid(self) -> u32;}pubstructDescentProfile<'a>{pubenv:&'a[(&'astr,&'astr)],pubcwd:&'aPath,}#[derive(Debug, thiserror::Error)]pubenumDescentError{InvalidCommand{command:String},InvalidEnvironment{name:String,reason:&'staticstr},// never carries a valueInvalidWorkingDirectory{cwd:PathBuf},InvalidOperator{uid:u32,gid:u32},}#[derive(Debug)]pubenumDescentSettle{Pending,Started{command_stderr_from:usize},NoWorkingDirectory{reason:String},Refused(ExecutorError),}implInDaemonExecutor{pubfndescent_command(&self,who:Descent,command:&str,args:&[&str],profile:&DescentProfile<'_>) -> Result<Command,DescentError>;pubfnsettle_descent(&self,stderr:&[u8],ended:bool) -> DescentSettle;}
Validation, before anything is built.command is an absolute path free of =. Each env name matches [A-Za-z_][A-Za-z0-9_]*, appears once, and its value contains no NUL. cwd is absolute and contains no NUL. OperatorIds::new refuses a uid or gid of 0 or u32::MAX ((uid_t)-1 means "unchanged" to the kernel). Errors never include an env value.
The command built. Both variants descend through sudo, from the same site that builds run's descent (resolve_through or a helper both call), so the sudo binary and the -u <account> words for a service account equal run's:
No -n, no -S, no password line: descent from root never prompts, as for run in the daemon. The fixed <script>, run as the target identity:
cd -- "$1"; on failure, print a fixed no-directory marker on stderr and exit non-zero;
shift, print SUDO_OK_SENTINEL on stderr;
exec /usr/bin/env -i "$@".
env -i takes the K=V words as the whole environment and the first word without =, the command, as the utility. So the command sees exactly profile.env, runs in profile.cwd, and keeps every argument as one word. Every value is one discrete argv word, never spliced into the script. The rustdoc warns that the K=V words are visible in the process table until exec, so no secret belongs in profile.env.
The returned Command has its program, arguments and working directory / set (so sudo never depends on the caller's directory), and nothing else: no stdio, no process_group, no setsid, no uid/gid, no pre_exec, no environment change. The caller sets stdio and its process group and spawns it. descent_command never spawns.
Why sudo for the operator.CommandExt::uid run as root drops every supplementary group (std calls setgroups(0, …)), and setting the operator's groups needs initgroups in a pre_exec, which is new unsafe. sudo -u #uid -g #gid sets the operator's uid, primary gid and group-database groups as a login would, goes through the same sentinel classification as a service account, and keeps one descent path. A host whose sudoers does not let root run as that uid and gid gives Refused; nothing falls back.
Classification.settle_descent classifies what the spawned child has written to stderr so far (ended is true once stderr reached EOF or the child exited):
sentinel present at an offset within TRANSPORT_STDERR_LIMIT (64 KiB): Started, where command_stderr_from is the offset just past it; later bytes are the command's;
sentinel present only beyond that limit: Refused(SudoRefused) with the bytes before it, capped at 64 KiB, as reason — the same rule Add a long-lived elevated channel to Executor #132's channel applies wherever the sentinel lands;
no-directory marker present: NoWorkingDirectory with the text before it;
neither, and ended: Refused(ExecutorError::SudoRefused { host, reason }), reason being the trimmed stderr, exactly as classify_elevation does with no SudoAuth;
neither, and more than 64 KiB not counting a trailing fragment that may still grow into the sentinel: Refused(SudoRefused) with the first 64 KiB as reason, even when not ended; the caller then kills the child;
otherwise Pending.
It never returns Started for bytes that merely contain the sentinel's prefix. The sentinel search and the limit rule are the ones judge in src/executor/channel.rs already implements for open_channel (#132): factor them into one private helper that both call, rather than a second copy; the no-directory marker is this issue's addition on top.
Environment the command does not get from this crate. Umask and resource limits are not set here: they are what the parent passes and sudo and its PAM session leave (sudoers umask is unioned with the parent's). The rustdoc says so. sudo closes descriptors above 2, so only stdio reaches the command. With no controlling terminal, as in a systemd service, sudo allocates no pty and runs the command without a new session; the rustdoc names this as the condition under which the command stays in the caller's process group.
Identity model.Descent and OperatorIds get no FromStr, Deserialize, From<String> or From<(u32, u32)>. Descent::Service takes the existing closed ServiceAccount, so a configured account name still cannot become an identity (RFC 0003 §9.2). Identity gains nothing and Identity::Operator in run, run_in, run_with_input and fetch_file inside the daemon still returns NoOperatorIdentity.
Unchanged.put_file, fetch_file, hard_link_over and their durability; run, run_with_input, open_channel (still the trait default Unsupported for InDaemonExecutor) and the other Executor methods; open_channel's start script and settling behaviour, which the shared helper must preserve; Identity, ServiceAccount, ExecutorError's variants; LocalExecutor and SshExecutor.
Relation to run_with_input. That stays the bounded, self-spawning primitive. descent_command only builds, for a root caller that spawns, bounds and kills itself; being root, the caller can signal the whole group, so no supervisor is needed.
Constraints. No new dependency, no new unsafe, no pre_exec, no as casts on ids, no bare unwrap(), no input-derived expect(). No CHANGELOG.md entry: deploy-core has no release.
Acceptance criteria
The items above exist as specified, with rustdoc and # Errors.
For Service, the words before /bin/sh equal run's InDaemonExecutor descent for that account; for Operator they are sudo -u #<uid> -g #<gid>.
The spawned command sees exactly profile.env, runs in profile.cwd, and receives every argument intact, including spaces, quotes, $(…), newlines, a leading -, and = after the command.
The returned Command sets no stdio, process group, session, uid, gid, environment or pre_exec; its working directory is /; descent_command spawns nothing.
A child spawned from it with process_group(P) reports P in /proc/<pid>/stat (Linux) for the sudo stub and for the command.
settle_descent returns each of its six outcomes as specified, and Refused matches classify_elevation's SudoRefused for the same stderr. Its sentinel and limit decisions come from the helper open_channel also uses, and Add a long-lived elevated channel to Executor #132's channel tests pass unchanged.
Invalid command, env name, duplicate name, NUL value, relative cwd, and uid or gid 0 or u32::MAX are refused before building, and no error text contains an env value.
Identity::Operator inside the daemon still returns NoOperatorIdentity from run and run_with_input; existing tests pass unchanged; no existing public signature or ExecutorError variant changes.
An #[ignore] test runs the real sudo as root to a real account and checks uid, gid, groups, environment, directory and process group; the PR description records one run of it (command, environment, sudo -V version, output), or states it was not run and why. CI does not run it.
The fmt, both clippy, doc and both test invocations CI runs pass, including the ubuntu-24.04-arm and macos-latest jobs.
Test plan
Unit tests in src/executor.rs's test module, with with_sudo_bin stubs written by write_script under tempfile::tempdir(); no real sudo, no network, no process-environment mutation. A descending stub must also skip -g <group>.
Argv. A recording stub prints its argv: Service(ServiceAccount::Insight) shows the same prefix run shows; Operator(OperatorIds::new(1000, 1000)) shows -u #1000 -g #1000; neither shows -n or -S.
Profile. Through a descending stub, /usr/bin/env prints exactly the six profile pairs, with a parent environment carrying an extra variable set only via Command::env on the returned Command; /bin/pwd prints cwd.
Arguments./usr/bin/printf '%s\n' with the awkward arguments listed above prints each back unchanged; a profile value with spaces and quotes arrives unchanged.
Process group (Linux). Spawn the Command with process_group(0) and stdin piped, over a stub that stays alive beside its child and over one that execs; the command is /bin/cat; while it blocks on stdin, /proc/<pid>/stat of the stub and of cat show the stub's own group; close stdin and both exit.
Settle. Sentinel split across two calls gives Pending, then Started with the right offset; a stub printing sudo: unknown user and exiting gives Refused; cwd a missing directory gives NoWorkingDirectory; 64 KiB plus one byte with no sentinel gives Refused while not ended; 64 KiB followed by a partial sentinel gives Pending; a sentinel arriving after more than 64 KiB gives Refused.
Validation. One case per refusal in the acceptance criteria.
Regression.operator_inside_the_daemon_refuses_and_runs_nothing and the other existing in-daemon tests pass unchanged.
Out of scope
Any bootler change or adoption (aicers/bootler#416 and #418 adopt this by a pin bump).
Root descent (bootler spawns its own root children), LocalExecutor and SshExecutor.
Spawning, bounding, cancelling or reaping the command; setting umask or resource limits; resolving an operator's passwd entry.
Changing sudoers, PAM or any host policy.
Dependencies
None open. #132 (Executor::open_channel, merged at 87a7b66) added src/executor/channel.rs in the same area; share its sentinel settling as described in Scope, and keep its tests passing unchanged.
src/executor.rs:572NoOperatorIdentity; :754ServiceAccount; :982Identity; :1220 the trait's default open_channel.
src/executor.rs:518SUDO_OK_SENTINEL, :2284sudo_sentinel_script, :2290take_sudo_sentinel, :2326classify_elevation; :501CD_EXEC_SCRIPT, the positional-argument pattern.
src/executor.rs:730check_bounded_command; src/executor/bounded.rs:65TRANSPORT_STDERR_LIMIT, :71ENV, :318 the supervisor's env -i usage.
src/executor/channel.rs:488judge (sentinel search, transport limit wherever the sentinel lands, trailing-fragment exclusion), :407settle_start, and its tests from :930.
Tests: src/executor.rs:3345write_script, :3784–:3841 the in-daemon resolution tests (operator_inside_the_daemon_refuses_and_runs_nothing at :3811), :6929descending_sudo.
Why
bootler's session helper is a root process inside a systemd transient service. It applies a pinned execution profile to every command it spawns: exactly
PATH,HOME,USER,LOGNAME,LANGandLC_ALL, and the admitted working directory (/forrun), with no TTY (aicers/bootler#416 Context and Scope; aicers/bootler#418 Scope item 3). Root children are bootler's own. Two identities need this crate:Service(account). The profile must be applied aftersudo -uidentity selection, becausesudo'senv_resetdiscards an outer environment (aicers/bootler#416 "Identity selection comes first").Operator. The command must run as the uid and gid the session handshake authenticated, never as root (aicers/bootler#418 Scope item 3).bootler must not re-implement
sudo -udescent, the sentinel script, elevation classification or any uid/gid switch (aicers/bootler#416, #418 Constraints). Its helper obtains astd::process::Command, sets stdio andprocess_group(P)and spawns it itself, so the identity-selecting process and its descendants stay in its anchored groupP; a hook that cannot yield such aCommandblocks aicers/bootler#418 (Scope item 3).InDaemonExecutorcan do neither: its resolution is private, it applies no environment or directory, and it refusesIdentity::OperatorwithNoOperatorIdentity.run_with_inputspawns internally, in its own process group, with an always-empty environment.Scope
Add to
src/executor.rs(all public items with rustdoc and# Errors; these names are decided, private helpers are the implementer's):Validation, before anything is built.
commandis an absolute path free of=. Each env name matches[A-Za-z_][A-Za-z0-9_]*, appears once, and its value contains no NUL.cwdis absolute and contains no NUL.OperatorIds::newrefuses a uid or gid of0oru32::MAX((uid_t)-1means "unchanged" to the kernel). Errors never include an env value.The command built. Both variants descend through
sudo, from the same site that buildsrun's descent (resolve_throughor a helper both call), so thesudobinary and the-u <account>words for a service account equalrun's:Service(account):sudo -u <account> /bin/sh -c <script> <arg0> <cwd> <K=V>… <command> <args…>Operator(ids):sudo -u #<uid> -g #<gid> /bin/sh -c <script> <arg0> <cwd> <K=V>… <command> <args…>No
-n, no-S, no password line: descent from root never prompts, as forrunin the daemon. The fixed<script>, run as the target identity:cd -- "$1"; on failure, print a fixed no-directory marker on stderr and exit non-zero;shift, printSUDO_OK_SENTINELon stderr;exec /usr/bin/env -i "$@".env -itakes theK=Vwords as the whole environment and the first word without=, the command, as the utility. So the command sees exactlyprofile.env, runs inprofile.cwd, and keeps every argument as one word. Every value is one discrete argv word, never spliced into the script. The rustdoc warns that theK=Vwords are visible in the process table untilexec, so no secret belongs inprofile.env.The returned
Commandhas its program, arguments and working directory/set (sosudonever depends on the caller's directory), and nothing else: no stdio, noprocess_group, nosetsid, nouid/gid, nopre_exec, no environment change. The caller sets stdio and its process group and spawns it.descent_commandnever spawns.Why
sudofor the operator.CommandExt::uidrun as root drops every supplementary group (stdcallssetgroups(0, …)), and setting the operator's groups needsinitgroupsin apre_exec, which is newunsafe.sudo -u #uid -g #gidsets the operator's uid, primary gid and group-database groups as a login would, goes through the same sentinel classification as a service account, and keeps one descent path. A host whose sudoers does not let root run as that uid and gid givesRefused; nothing falls back.Classification.
settle_descentclassifies what the spawned child has written to stderr so far (endedis true once stderr reached EOF or the child exited):TRANSPORT_STDERR_LIMIT(64 KiB):Started, wherecommand_stderr_fromis the offset just past it; later bytes are the command's;Refused(SudoRefused)with the bytes before it, capped at 64 KiB, as reason — the same rule Add a long-lived elevated channel to Executor #132's channel applies wherever the sentinel lands;NoWorkingDirectorywith the text before it;ended:Refused(ExecutorError::SudoRefused { host, reason }),reasonbeing the trimmed stderr, exactly asclassify_elevationdoes with noSudoAuth;Refused(SudoRefused)with the first 64 KiB as reason, even when notended; the caller then kills the child;Pending.It never returns
Startedfor bytes that merely contain the sentinel's prefix. The sentinel search and the limit rule are the onesjudgeinsrc/executor/channel.rsalready implements foropen_channel(#132): factor them into one private helper that both call, rather than a second copy; the no-directory marker is this issue's addition on top.Environment the command does not get from this crate. Umask and resource limits are not set here: they are what the parent passes and
sudoand its PAM session leave (sudoersumaskis unioned with the parent's). The rustdoc says so.sudocloses descriptors above 2, so only stdio reaches the command. With no controlling terminal, as in a systemd service,sudoallocates no pty and runs the command without a new session; the rustdoc names this as the condition under which the command stays in the caller's process group.Identity model.
DescentandOperatorIdsget noFromStr,Deserialize,From<String>orFrom<(u32, u32)>.Descent::Servicetakes the existing closedServiceAccount, so a configured account name still cannot become an identity (RFC 0003 §9.2).Identitygains nothing andIdentity::Operatorinrun,run_in,run_with_inputandfetch_fileinside the daemon still returnsNoOperatorIdentity.Unchanged.
put_file,fetch_file,hard_link_overand their durability;run,run_with_input,open_channel(still the trait defaultUnsupportedforInDaemonExecutor) and the otherExecutormethods;open_channel's start script and settling behaviour, which the shared helper must preserve;Identity,ServiceAccount,ExecutorError's variants;LocalExecutorandSshExecutor.Relation to
run_with_input. That stays the bounded, self-spawning primitive.descent_commandonly builds, for a root caller that spawns, bounds and kills itself; being root, the caller can signal the whole group, so no supervisor is needed.Constraints. No new dependency, no new
unsafe, nopre_exec, noascasts on ids, no bareunwrap(), no input-derivedexpect(). NoCHANGELOG.mdentry: deploy-core has no release.Acceptance criteria
# Errors.Service, the words before/bin/shequalrun'sInDaemonExecutordescent for that account; forOperatorthey aresudo -u #<uid> -g #<gid>.profile.env, runs inprofile.cwd, and receives every argument intact, including spaces, quotes,$(…), newlines, a leading-, and=after the command.Commandsets no stdio, process group, session, uid, gid, environment orpre_exec; its working directory is/;descent_commandspawns nothing.process_group(P)reportsPin/proc/<pid>/stat(Linux) for thesudostub and for the command.settle_descentreturns each of its six outcomes as specified, andRefusedmatchesclassify_elevation'sSudoRefusedfor the same stderr. Its sentinel and limit decisions come from the helperopen_channelalso uses, and Add a long-lived elevated channel to Executor #132's channel tests pass unchanged.0oru32::MAXare refused before building, and no error text contains an env value.Identity::Operatorinside the daemon still returnsNoOperatorIdentityfromrunandrun_with_input; existing tests pass unchanged; no existing public signature orExecutorErrorvariant changes.#[ignore]test runs the realsudoas root to a real account and checks uid, gid, groups, environment, directory and process group; the PR description records one run of it (command, environment,sudo -Vversion, output), or states it was not run and why. CI does not run it.fmt, bothclippy,docand bothtestinvocations CI runs pass, including theubuntu-24.04-armandmacos-latestjobs.Test plan
Unit tests in
src/executor.rs's test module, withwith_sudo_binstubs written bywrite_scriptundertempfile::tempdir(); no realsudo, no network, no process-environment mutation. A descending stub must also skip-g <group>.Service(ServiceAccount::Insight)shows the same prefixrunshows;Operator(OperatorIds::new(1000, 1000))shows-u #1000 -g #1000; neither shows-nor-S./usr/bin/envprints exactly the six profile pairs, with a parent environment carrying an extra variable set only viaCommand::envon the returnedCommand;/bin/pwdprintscwd./usr/bin/printf '%s\n'with the awkward arguments listed above prints each back unchanged; a profile value with spaces and quotes arrives unchanged.Commandwithprocess_group(0)and stdin piped, over a stub that stays alive beside its child and over one thatexecs; the command is/bin/cat; while it blocks on stdin,/proc/<pid>/statof the stub and ofcatshow the stub's own group; close stdin and both exit.Pending, thenStartedwith the right offset; a stub printingsudo: unknown userand exiting givesRefused;cwda missing directory givesNoWorkingDirectory; 64 KiB plus one byte with no sentinel givesRefusedwhile notended; 64 KiB followed by a partial sentinel givesPending; a sentinel arriving after more than 64 KiB givesRefused.operator_inside_the_daemon_refuses_and_runs_nothingand the other existing in-daemon tests pass unchanged.Out of scope
Rootdescent (bootler spawns its own root children),LocalExecutorandSshExecutor.Dependencies
None open. #132 (
Executor::open_channel, merged at87a7b66) addedsrc/executor/channel.rsin the same area; share its sentinel settling as described in Scope, and keep its tests passing unchanged.Pointers
At
87a7b66(deploy-coremainafter #132):src/executor.rs:3081InDaemonExecutor,:3109resolve,:3123resolve_through(itsNoOperatorIdentityarm),:3156spawn_resolved,:3176run_bounded;:3216impl Executor for InDaemonExecutor.src/executor.rs:572NoOperatorIdentity;:754ServiceAccount;:982Identity;:1220the trait's defaultopen_channel.src/executor.rs:518SUDO_OK_SENTINEL,:2284sudo_sentinel_script,:2290take_sudo_sentinel,:2326classify_elevation;:501CD_EXEC_SCRIPT, the positional-argument pattern.src/executor.rs:730check_bounded_command;src/executor/bounded.rs:65TRANSPORT_STDERR_LIMIT,:71ENV,:318the supervisor'senv -iusage.src/executor/channel.rs:488judge(sentinel search, transport limit wherever the sentinel lands, trailing-fragment exclusion),:407settle_start, and its tests from:930.src/executor.rs:3345write_script,:3784–:3841the in-daemon resolution tests (operator_inside_the_daemon_refuses_and_runs_nothingat:3811),:6929descending_sudo.