├── .clippy.toml ├── .dockerignore ├── .github ├── renovate.json └── workflows │ ├── audit.yml │ └── tests.yml ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── ego.iml ├── encodings.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── runConfigurations │ ├── cargo_build.xml │ ├── cargo_clippy.xml │ ├── cargo_fix.xml │ ├── cargo_run.xml │ └── cargo_test.xml ├── vcs.xml └── watcherTasks.xml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── cli.rs ├── errors.rs ├── logging.rs ├── main.rs ├── snapshots │ ├── check_user_homedir.txt │ └── ego.help ├── tests.rs ├── util.rs └── x11.rs └── varia ├── Dockerfile.tests ├── README.md ├── ego-completion.bash ├── ego-completion.fish ├── ego-completion.zsh ├── ego.rules ├── ego.sudoers ├── ego.sysusers.conf └── ego.tmpfiles.conf /.clippy.toml: -------------------------------------------------------------------------------- 1 | allow-mixed-uninlined-format-args = false 2 | doc-valid-idents = ["PulseAudio"] 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /target 2 | _local 3 | .idea 4 | .git 5 | .github 6 | varia/Dockerfile.tests 7 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "mergeConfidence:all-badges", 4 | "config:recommended" 5 | ], 6 | "rangeStrategy": "bump", 7 | "lockFileMaintenance": { 8 | "enabled": true, 9 | "schedule": ["before 5am on saturday"] 10 | }, 11 | "packageRules": [ 12 | { 13 | "matchManagers": ["cargo"], 14 | "matchPackageNames": ["clap", "clap_complete", "clap_builder", "clap_lex"], 15 | "groupName": "Clap updates" 16 | }, 17 | { 18 | "matchManagers": ["cargo"], 19 | "matchUpdateTypes": ["patch"], 20 | "groupName": "Cargo patch", 21 | "schedule": ["before 5am on saturday"] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | # Doc: https://github.com/actions-rs/audit-check#scheduled-audit 2 | name: Cargo packages audit 3 | on: 4 | schedule: 5 | # 16:21 UTC on Wednesdays 6 | - cron: "21 16 * * WED" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | audit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions-rs/audit-check@v1 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | schedule: 7 | # 16:21 UTC on Tuesdays 8 | - cron: "21 16 * * TUE" 9 | repository_dispatch: 10 | types: [tests] 11 | 12 | env: 13 | DOCKER_BUILDKIT: 1 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build 20 | run: docker build . --pull -f varia/Dockerfile.tests --tag ego-build 21 | - name: Test suite 22 | run: docker run --rm ego-build cargo test --color=always 23 | - name: Clippy lints 24 | run: docker run --rm ego-build cargo clippy --color=always --all-targets --all-features -- -D warnings 25 | - name: rustfmt 26 | run: docker run --rm ego-build cargo fmt -- --color=always --check 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | _local/ 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/ego.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations/cargo_build.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | -------------------------------------------------------------------------------- /.idea/runConfigurations/cargo_clippy.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | -------------------------------------------------------------------------------- /.idea/runConfigurations/cargo_fix.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | -------------------------------------------------------------------------------- /.idea/runConfigurations/cargo_run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | -------------------------------------------------------------------------------- /.idea/runConfigurations/cargo_test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 24 | 25 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "acl-sys" 7 | version = "1.2.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "bbc079f9bdd3124fd18df23c67f7e0f79d24751ae151dcffd095fcade07a3eb2" 10 | dependencies = [ 11 | "libc", 12 | ] 13 | 14 | [[package]] 15 | name = "anstream" 16 | version = "0.6.18" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 19 | dependencies = [ 20 | "anstyle", 21 | "anstyle-parse", 22 | "anstyle-query", 23 | "anstyle-wincon", 24 | "colorchoice", 25 | "is_terminal_polyfill", 26 | "utf8parse", 27 | ] 28 | 29 | [[package]] 30 | name = "anstyle" 31 | version = "1.0.10" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 34 | 35 | [[package]] 36 | name = "anstyle-parse" 37 | version = "0.2.6" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 40 | dependencies = [ 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-query" 46 | version = "1.1.2" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 49 | dependencies = [ 50 | "windows-sys", 51 | ] 52 | 53 | [[package]] 54 | name = "anstyle-wincon" 55 | version = "3.0.6" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 58 | dependencies = [ 59 | "anstyle", 60 | "windows-sys", 61 | ] 62 | 63 | [[package]] 64 | name = "bitflags" 65 | version = "1.3.2" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 68 | 69 | [[package]] 70 | name = "bitflags" 71 | version = "2.6.0" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 74 | 75 | [[package]] 76 | name = "cfg-if" 77 | version = "1.0.0" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 80 | 81 | [[package]] 82 | name = "cfg_aliases" 83 | version = "0.2.1" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 86 | 87 | [[package]] 88 | name = "clap" 89 | version = "4.5.36" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" 92 | dependencies = [ 93 | "clap_builder", 94 | ] 95 | 96 | [[package]] 97 | name = "clap_builder" 98 | version = "4.5.36" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" 101 | dependencies = [ 102 | "anstream", 103 | "anstyle", 104 | "clap_lex", 105 | "strsim", 106 | ] 107 | 108 | [[package]] 109 | name = "clap_complete" 110 | version = "4.5.47" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "c06f5378ea264ad4f82bbc826628b5aad714a75abf6ece087e923010eb937fb6" 113 | dependencies = [ 114 | "clap", 115 | ] 116 | 117 | [[package]] 118 | name = "clap_lex" 119 | version = "0.7.4" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 122 | 123 | [[package]] 124 | name = "colorchoice" 125 | version = "1.0.3" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 128 | 129 | [[package]] 130 | name = "ego" 131 | version = "1.1.7" 132 | dependencies = [ 133 | "anstyle", 134 | "clap", 135 | "clap_complete", 136 | "log", 137 | "nix", 138 | "posix-acl", 139 | "shell-words", 140 | "simple-error", 141 | "snapbox", 142 | "testing_logger", 143 | "xcb", 144 | ] 145 | 146 | [[package]] 147 | name = "is_terminal_polyfill" 148 | version = "1.70.1" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 151 | 152 | [[package]] 153 | name = "libc" 154 | version = "0.2.172" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 157 | 158 | [[package]] 159 | name = "log" 160 | version = "0.4.27" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 163 | 164 | [[package]] 165 | name = "memchr" 166 | version = "2.7.4" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 169 | 170 | [[package]] 171 | name = "nix" 172 | version = "0.30.0" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "537bc3c4a347b87fd52ac6c03a02ab1302962cfd93373c5d7a112cdc337854cc" 175 | dependencies = [ 176 | "bitflags 2.6.0", 177 | "cfg-if", 178 | "cfg_aliases", 179 | "libc", 180 | ] 181 | 182 | [[package]] 183 | name = "normalize-line-endings" 184 | version = "0.3.0" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" 187 | 188 | [[package]] 189 | name = "posix-acl" 190 | version = "1.2.0" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "9928b761309e4a4ca4f2d90eb03029142e3f7164107e18db4d46516515b87441" 193 | dependencies = [ 194 | "acl-sys", 195 | "libc", 196 | ] 197 | 198 | [[package]] 199 | name = "quick-xml" 200 | version = "0.30.0" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" 203 | dependencies = [ 204 | "memchr", 205 | ] 206 | 207 | [[package]] 208 | name = "shell-words" 209 | version = "1.1.0" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" 212 | 213 | [[package]] 214 | name = "similar" 215 | version = "2.6.0" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" 218 | 219 | [[package]] 220 | name = "simple-error" 221 | version = "0.3.1" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "7e2accd2c41a0e920d2abd91b2badcfa1da784662f54fbc47e0e3a51f1e2e1cf" 224 | 225 | [[package]] 226 | name = "snapbox" 227 | version = "0.6.21" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "96dcfc4581e3355d70ac2ee14cfdf81dce3d85c85f1ed9e2c1d3013f53b3436b" 230 | dependencies = [ 231 | "anstream", 232 | "anstyle", 233 | "normalize-line-endings", 234 | "similar", 235 | "snapbox-macros", 236 | ] 237 | 238 | [[package]] 239 | name = "snapbox-macros" 240 | version = "0.3.10" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "16569f53ca23a41bb6f62e0a5084aa1661f4814a67fa33696a79073e03a664af" 243 | dependencies = [ 244 | "anstream", 245 | ] 246 | 247 | [[package]] 248 | name = "strsim" 249 | version = "0.11.1" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 252 | 253 | [[package]] 254 | name = "testing_logger" 255 | version = "0.1.1" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "6d92b727cb45d33ae956f7f46b966b25f1bc712092aeef9dba5ac798fc89f720" 258 | dependencies = [ 259 | "log", 260 | ] 261 | 262 | [[package]] 263 | name = "utf8parse" 264 | version = "0.2.2" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 267 | 268 | [[package]] 269 | name = "windows-sys" 270 | version = "0.59.0" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 273 | dependencies = [ 274 | "windows-targets", 275 | ] 276 | 277 | [[package]] 278 | name = "windows-targets" 279 | version = "0.52.6" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 282 | dependencies = [ 283 | "windows_aarch64_gnullvm", 284 | "windows_aarch64_msvc", 285 | "windows_i686_gnu", 286 | "windows_i686_gnullvm", 287 | "windows_i686_msvc", 288 | "windows_x86_64_gnu", 289 | "windows_x86_64_gnullvm", 290 | "windows_x86_64_msvc", 291 | ] 292 | 293 | [[package]] 294 | name = "windows_aarch64_gnullvm" 295 | version = "0.52.6" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 298 | 299 | [[package]] 300 | name = "windows_aarch64_msvc" 301 | version = "0.52.6" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 304 | 305 | [[package]] 306 | name = "windows_i686_gnu" 307 | version = "0.52.6" 308 | source = "registry+https://github.com/rust-lang/crates.io-index" 309 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 310 | 311 | [[package]] 312 | name = "windows_i686_gnullvm" 313 | version = "0.52.6" 314 | source = "registry+https://github.com/rust-lang/crates.io-index" 315 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 316 | 317 | [[package]] 318 | name = "windows_i686_msvc" 319 | version = "0.52.6" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 322 | 323 | [[package]] 324 | name = "windows_x86_64_gnu" 325 | version = "0.52.6" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 328 | 329 | [[package]] 330 | name = "windows_x86_64_gnullvm" 331 | version = "0.52.6" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 334 | 335 | [[package]] 336 | name = "windows_x86_64_msvc" 337 | version = "0.52.6" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 340 | 341 | [[package]] 342 | name = "xcb" 343 | version = "1.5.0" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "f1e2f212bb1a92cd8caac8051b829a6582ede155ccb60b5d5908b81b100952be" 346 | dependencies = [ 347 | "bitflags 1.3.2", 348 | "libc", 349 | "quick-xml", 350 | ] 351 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ego" 3 | version = "1.1.7" 4 | edition = "2021" 5 | rust-version = "1.74.0" 6 | 7 | # Metadata 8 | authors = ["Marti Raudsepp "] 9 | description = "Alter Ego: run Linux desktop applications under a different local user" 10 | readme = "README.md" 11 | license = "MIT" 12 | homepage = "https://github.com/intgr/ego" 13 | repository = "https://github.com/intgr/ego" 14 | keywords = ["sudo", "security", "wayland", "pulseaudio"] 15 | categories = ["command-line-utilities"] 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [dependencies] 19 | simple-error = "0.3.1" 20 | posix-acl = "1.2.0" 21 | clap = { version = "~4.5.36", features = ["cargo"] } 22 | log = { version = "0.4.27", features = ["std"] } 23 | shell-words = "1.1.0" 24 | nix = { version = "0.30.0", default-features = false, features = ["user"] } 25 | anstyle = "1.0.10" 26 | xcb = "1.5.0" 27 | 28 | [features] 29 | default = [] 30 | 31 | [dev-dependencies] 32 | clap_complete = "~4.5.47" 33 | snapbox = "0.6.21" 34 | testing_logger = "0.1.1" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 Marti Raudsepp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ego (a.k.a Alter Ego) 2 | ===================== 3 | 4 | [![Crates.io version](https://img.shields.io/crates/v/ego.svg)](https://crates.io/crates/ego) 5 | [![Tests status](https://github.com/intgr/ego/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/intgr/ego/actions?query=workflow:Tests) 6 | 7 | > Do all your games need access to your documents, browser history, SSH private keys? 8 | > 9 | > ... No? Just run `ego steam`! 10 | 11 | **Ego** is a tool to run Linux desktop applications under a different local user. Currently 12 | integrates with Wayland, Xorg, PulseAudio and xdg-desktop-portal. You may think of it as `xhost` 13 | for Wayland and PulseAudio. This is done using filesystem ACLs and X11 host access control. 14 | 15 | Disclaimer: **DO NOT RUN UNTRUSTED PROGRAMS VIA EGO.** However, using ego is more secure than 16 | running applications directly under your primary user. 17 | 18 | Distro packages 19 | --------------- 20 | Distribution packages are available for: 21 | * Arch Linux (user-contributed package) https://aur.archlinux.org/packages/ego/ 22 | 23 | After installing the package, add yourself to the `ego-users` group. After logout and login, 24 | the `ego` command should just work. 25 | 26 | ([varia/README.md](varia/README.md) documents recommendations for distro packagers) 27 | 28 | Manual setup 29 | ------------ 30 | Ego aims to come with sane defaults and be easy to set up. 31 | 32 | **Requirements:** 33 | * [Rust & cargo](https://www.rust-lang.org/tools/install) 34 | * `libacl.so` library (Debian/Ubuntu: libacl1-dev; Fedora: libacl-devel; Arch: acl) 35 | * `libxcb.so` library (Debian/Ubuntu: libxcb1-dev; Fedora: libxcb-devel; Arch: libxcb) 36 | 37 | **Recommended:** (Not needed when using `--sudo` mode, but some desktop functionality may not work). 38 | * `machinectl` command (Debian/Ubuntu/Fedora: systemd-container; Arch: systemd) 39 | * `xdg-desktop-portal-gtk` (Debian/Ubuntu/Fedora/Arch: xdg-desktop-portal-gtk) 40 | 41 | **Installation:** 42 | 43 | 1. Run: 44 | 45 | cargo install ego 46 | sudo cp ~/.cargo/bin/ego /usr/local/bin/ 47 | 48 | 2. Create local user named "ego": [1] 49 | 50 | sudo useradd ego --uid 155 --create-home 51 | 52 | 3. That's all, try it: 53 | 54 | ego xdg-open . 55 | 56 | [1] No extra groups are needed by the ego user. 57 | UID below 1000 hides this user on the login screen. 58 | 59 | ### Avoid password prompt 60 | If using "machinectl" mode (default if available), you need the rather new systemd version >=247 61 | and polkit >=0.106 to do this securely. 62 | 63 | Create file `/etc/polkit-1/rules.d/50-ego-machinectl.rules`, polkit will automatically load it 64 | (replace `` with your own username): 65 | 66 | ```js 67 | polkit.addRule(function(action, subject) { 68 | if (action.id == "org.freedesktop.machine1.host-shell" && 69 | action.lookup("user") == "ego" && 70 | subject.user == "") { 71 | return polkit.Result.YES; 72 | } 73 | }); 74 | ``` 75 | 76 | ##### sudo mode 77 | For sudo, add the following to `/etc/sudoers` (replace `` with your own username): 78 | 79 | ALL=(ego) NOPASSWD:ALL 80 | 81 | Changelog 82 | --------- 83 | 84 | ##### Unreleased 85 | * Use X11 protocol directly via `libxcb`. The `xhost` dependency is no longer needed. (#163) 86 | 87 | ##### 1.1.7 (2023-06-26) 88 | * Distro packaging: added tmpfiles.d conf to create missing ego user home directory (#134, fixed issue #131) 89 | * Ego now detects and warns when target user's home directory does not exist or has wrong ownership (#139) 90 | * Minimum Supported Rust Version (MSRV) is now 1.64.0 (#116) 91 | * Various minor cleanups, replaced unmaintained dependencies, dependency updates. 92 | 93 | ##### 1.1.6 (2023-01-21) 94 | * Updated to clap 4.0.x (#101) and many other dependency updates 95 | * Fixes for new clippy lints (#95, #93, #111) 96 | * Use `snapbox` instead of hand-coded snapshot testing (#102) 97 | * Minimum Supported Rust Version (MSRV) was determined to be 1.60.0 (#113) 98 | 99 | ##### 1.1.5 (2022-01-02) 100 | * Document xhost requirement, improve xhost error reporting (#76) 101 | * Upgrade to clap 3.0.0 stable (#71) 102 | 103 | (Version 1.1.4 was yanked, it was accidentally released with a regression) 104 | 105 | ##### 1.1.3 (2021-11-12) 106 | * Pin clap version (fixes #65) (#68) 107 | 108 | ##### 1.1.2 (2021-05-08) 109 | * Enable sudo askpass helper if SUDO_ASKPASS is set (#58) 110 | * Example how to set up a GUI password prompt with sudo: https://askubuntu.com/a/314401 111 | * Note: For a GUI password prompt with the machinectl mode, you need to run a 112 | Polkit authentication agent instead 113 | 114 | ##### 1.1.1 (2021-03-23) 115 | * Include drop-in files for polkit, sudoers.d, sysusers.d -- for distro packages (#53) 116 | * Documentation tweaks (#51, #53) 117 | 118 | ##### 1.1.0 (2021-03-07) 119 | * Default to `machinectl` if available, fall back to `sudo` otherwise (#47) 120 | * Documentation & minor improvements (#46, #48) 121 | 122 | ##### 0.4.1 (2021-01-29) 123 | * Fixed `--machinectl` on Ubuntu, Debian with dash shell (#42) 124 | * Fixed error reporting when command execution fails (#43) 125 | * Documented how to avoid password prompt with machinectl & other doc tweaks (#41) 126 | 127 | ##### 0.4.0 (2021-01-29) 128 | * Improved integration with desktop environments: 129 | * Launch xdg-desktop-portal-gtk in machinectl session (#6, #31) 130 | * Old behavior is still available via `--machinectl-bare` switch. 131 | * Shell completion files are now auto-generated with clap-generate 3.0.0-beta.2 (#36, #28) 132 | * bash, zsh and fish shells are supported out of the box. 133 | * Code reorganization and CI improvements (#21, #23) 134 | * Dependency updates (#20, #24, #27, #22, #26, #33, #35, #38, #37, #39) 135 | 136 | ##### 0.3.1 (2020-03-17) 137 | * Improved error message for missing target user (#16) 138 | 139 | ##### 0.3.0 (2020-03-02) 140 | * Initial machinectl support (using `--machinectl`) (#8) 141 | * Updated: posix-acl (#9) 142 | 143 | ##### 0.2.0 (2020-02-17) 144 | * Added zsh completion support (#5) 145 | * Added `--verbose` flag (#4) 146 | * Added `--user` argument and command-line parsing (#3) 147 | 148 | ##### 0.1.0 (2020-02-13) 149 | Initial version 150 | 151 | Appendix 152 | -------- 153 | Ego is licensed under the MIT License (see the `LICENSE` file). Ego was created by Marti Raudsepp. 154 | Ego's primary website is at https://github.com/intgr/ego 155 | 156 | Thanks to Alexander Payne (myrrlyn) for relinquishing the unused "ego" crate name. 157 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{command, Arg, ArgAction, ArgGroup, Command, ValueHint}; 2 | use log::Level; 3 | use std::ffi::OsString; 4 | 5 | #[derive(Debug, PartialEq, Eq)] 6 | pub enum Method { 7 | Sudo, 8 | Machinectl, 9 | MachinectlBare, 10 | } 11 | 12 | /// Data type for parsed settings 13 | pub struct Args { 14 | pub user: String, 15 | pub command: Vec, 16 | pub log_level: Level, 17 | pub method: Option, 18 | pub old_xhost: bool, 19 | } 20 | 21 | pub fn build_cli() -> Command { 22 | command!() 23 | .arg( 24 | Arg::new("user") 25 | .short('u') 26 | .long("user") 27 | .value_name("USER") 28 | .default_value("ego") 29 | .help("Specify a username (default: ego)") 30 | .value_hint(ValueHint::Username), 31 | ) 32 | .arg( 33 | Arg::new("sudo") 34 | .long("sudo") 35 | .action(ArgAction::SetTrue) 36 | .help("Use 'sudo' to change user"), 37 | ) 38 | .arg( 39 | Arg::new("machinectl") 40 | .long("machinectl") 41 | .action(ArgAction::SetTrue) 42 | .help("Use 'machinectl' to change user (default, if available)"), 43 | ) 44 | .arg( 45 | Arg::new("machinectl-bare") 46 | .long("machinectl-bare") 47 | .action(ArgAction::SetTrue) 48 | .help("Use 'machinectl' but skip xdg-desktop-portal setup"), 49 | ) 50 | .group(ArgGroup::new("method").args(["sudo", "machinectl", "machinectl-bare"])) 51 | .arg( 52 | Arg::new("old-xhost") 53 | .long("old-xhost") 54 | .action(ArgAction::SetTrue) 55 | .help("Execute 'xhost' command instead of connecting to X11 directly"), 56 | ) 57 | .arg( 58 | Arg::new("command") 59 | .help("Command name and arguments to run (default: user shell)") 60 | .num_args(1..) 61 | .trailing_var_arg(true) 62 | .value_hint(ValueHint::CommandWithArguments), 63 | ) 64 | .arg( 65 | Arg::new("verbose") 66 | .short('v') 67 | .long("verbose") 68 | .action(ArgAction::Count) 69 | .help("Verbose output. Use multiple times for more output."), 70 | ) 71 | } 72 | 73 | pub fn parse_args + Clone>(args: impl IntoIterator) -> Args { 74 | let matches = build_cli().get_matches_from(args); 75 | 76 | Args { 77 | user: matches.get_one::("user").unwrap().clone(), 78 | command: matches 79 | .get_many("command") 80 | .unwrap_or_default() 81 | .cloned() 82 | .collect(), 83 | log_level: match matches.get_count("verbose") { 84 | 0 => Level::Warn, 85 | 1 => Level::Info, 86 | 2 => Level::Debug, 87 | _ => Level::Trace, 88 | }, 89 | old_xhost: matches.get_flag("old-xhost"), 90 | method: if matches.get_flag("machinectl") { 91 | Some(Method::Machinectl) 92 | } else if matches.get_flag("machinectl-bare") { 93 | Some(Method::MachinectlBare) 94 | } else if matches.get_flag("sudo") { 95 | Some(Method::Sudo) 96 | } else { 97 | None 98 | }, 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | //! Error handling helpers and the `ErrorWithHint` type for more verbose error messages. 2 | 3 | use crate::util::paint; 4 | use anstyle::{AnsiColor, Color, Style}; 5 | use log::error; 6 | use std::error::Error; 7 | use std::fmt; 8 | 9 | /// Shorter alias for `Box` 10 | pub type AnyErr = Box; 11 | 12 | const COLOR_HINT: Style = Style::new() 13 | .fg_color(Some(Color::Ansi(AnsiColor::Green))) 14 | .bold(); 15 | 16 | /// Advanced error type that can supply hints to the user 17 | #[derive(Debug)] 18 | pub struct ErrorWithHint { 19 | err: String, 20 | hint: String, 21 | } 22 | 23 | impl ErrorWithHint { 24 | pub fn new(err: String, hint: String) -> ErrorWithHint { 25 | ErrorWithHint { err, hint } 26 | } 27 | } 28 | 29 | impl Error for ErrorWithHint {} 30 | 31 | impl fmt::Display for ErrorWithHint { 32 | #[inline] 33 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 34 | self.err.fmt(f)?; 35 | 36 | if !self.hint.is_empty() { 37 | write!(f, "\n{}: {}", paint(COLOR_HINT, "hint"), self.hint)?; 38 | } 39 | Ok(()) 40 | } 41 | } 42 | 43 | pub fn print_error(err: &AnyErr) { 44 | error!("{err}"); 45 | } 46 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | //! Logging for command line output. 2 | //! Adapted from `simple_logger` by Sam Clements: 3 | 4 | use crate::util::paint; 5 | use anstyle::{AnsiColor, Color, Style}; 6 | use log::{trace, Level, Log, Metadata, Record}; 7 | 8 | struct SimpleLogger { 9 | level: Level, 10 | } 11 | 12 | const COLOR_TRACE: Style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Magenta))); 13 | const COLOR_WARN: Style = Style::new() 14 | .fg_color(Some(Color::Ansi(AnsiColor::Yellow))) 15 | .bold(); 16 | const COLOR_ERROR: Style = Style::new() 17 | .fg_color(Some(Color::Ansi(AnsiColor::Red))) 18 | .bold(); 19 | 20 | impl Log for SimpleLogger { 21 | fn enabled(&self, metadata: &Metadata) -> bool { 22 | metadata.level() <= self.level 23 | } 24 | 25 | fn log(&self, record: &Record) { 26 | if !self.enabled(record.metadata()) { 27 | return; 28 | } 29 | match record.level() { 30 | Level::Trace => { 31 | let target = if record.target().is_empty() { 32 | record.module_path().unwrap_or_default() 33 | } else { 34 | record.target() 35 | }; 36 | println!("[{}] {}", paint(COLOR_TRACE, target), record.args()); 37 | } 38 | Level::Warn => { 39 | println!("{}: {}", paint(COLOR_WARN, "warning"), record.args()); 40 | } 41 | Level::Error => { 42 | println!("{}: {}", paint(COLOR_ERROR, "error"), record.args()); 43 | } 44 | _ => { 45 | println!("{}", record.args()); 46 | } 47 | } 48 | } 49 | 50 | fn flush(&self) {} 51 | } 52 | 53 | /// Initializes the global logger with a `SimpleLogger` instance with 54 | /// `max_log_level` set to a specific log level. 55 | pub fn init_with_level(level: Level) { 56 | let logger = SimpleLogger { level }; 57 | log::set_boxed_logger(Box::new(logger)).expect("Set logger failed"); 58 | log::set_max_level(level.to_level_filter()); 59 | 60 | trace!("Log level {level}"); 61 | } 62 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::pedantic)] 2 | #![warn(clippy::cargo)] 3 | #![allow(clippy::module_name_repetitions)] 4 | #![allow(clippy::multiple_crate_versions)] 5 | 6 | #[macro_use] 7 | extern crate simple_error; 8 | 9 | use crate::cli::{parse_args, Method}; 10 | use crate::errors::{print_error, AnyErr, ErrorWithHint}; 11 | use crate::util::{exec_command, have_command, run_command, sd_booted}; 12 | use crate::x11::x11_add_acl; 13 | use log::{debug, info, log, warn, Level}; 14 | use nix::libc::uid_t; 15 | use nix::unistd::{Uid, User}; 16 | use posix_acl::{PosixACL, Qualifier, ACL_EXECUTE, ACL_READ, ACL_RWX}; 17 | use simple_error::SimpleError; 18 | use std::env::VarError; 19 | use std::fs::DirBuilder; 20 | use std::io::ErrorKind::PermissionDenied; 21 | use std::os::unix::fs::DirBuilderExt; 22 | use std::os::unix::fs::MetadataExt; 23 | use std::os::unix::fs::PermissionsExt; 24 | use std::path::{Path, PathBuf}; 25 | use std::process::exit; 26 | use std::{env, fs}; 27 | 28 | mod cli; 29 | mod errors; 30 | mod logging; 31 | #[cfg(test)] 32 | mod tests; 33 | mod util; 34 | mod x11; 35 | 36 | #[derive(Clone)] 37 | struct EgoContext { 38 | runtime_dir: PathBuf, 39 | target_user: String, 40 | target_uid: uid_t, 41 | target_user_shell: PathBuf, 42 | target_user_homedir: PathBuf, 43 | } 44 | 45 | fn main_inner() -> Result<(), AnyErr> { 46 | let args = parse_args(env::args()); 47 | logging::init_with_level(args.log_level); 48 | 49 | let mut vars: Vec = Vec::new(); 50 | let ctx = create_context(&args.user)?; 51 | 52 | info!( 53 | "Setting up Alter Ego for target user {} ({})", 54 | ctx.target_user, ctx.target_uid 55 | ); 56 | 57 | check_user_homedir(&ctx); 58 | 59 | let ret = prepare_runtime_dir(&ctx); 60 | if let Err(msg) = ret { 61 | bail!("Error preparing runtime dir: {msg}"); 62 | } 63 | match prepare_wayland(&ctx) { 64 | Err(msg) => bail!("Error preparing Wayland: {msg}"), 65 | Ok(ret) => vars.extend(ret), 66 | } 67 | match prepare_x11(&ctx, args.old_xhost) { 68 | Err(msg) => bail!("Error preparing X11: {msg}"), 69 | Ok(ret) => vars.extend(ret), 70 | } 71 | match prepare_pulseaudio(&ctx) { 72 | Err(msg) => bail!("Error preparing PulseAudio: {msg}"), 73 | Ok(ret) => vars.extend(ret), 74 | } 75 | 76 | let method = args.method.unwrap_or_else(detect_method); 77 | let ret = match method { 78 | Method::Sudo => run_sudo_command(&ctx, vars, args.command), 79 | Method::Machinectl => run_machinectl_command(&ctx, &vars, args.command, false), 80 | Method::MachinectlBare => run_machinectl_command(&ctx, &vars, args.command, true), 81 | }; 82 | if let Err(msg) = ret { 83 | bail!("{msg}"); 84 | } 85 | 86 | Ok(()) 87 | } 88 | 89 | fn main() { 90 | let ret = main_inner(); 91 | if let Err(err) = ret { 92 | print_error(&err); 93 | exit(1); 94 | } 95 | } 96 | 97 | /// Optionally get an environment variable. 98 | /// Returns `Ok(None)` for missing env variable. 99 | fn getenv_optional(key: &str) -> Result, SimpleError> { 100 | match env::var(key) { 101 | Ok(val) => Ok(Some(val)), 102 | Err(VarError::NotPresent) => Ok(None), 103 | // We could use Path type for non-Unicode paths, but it's not worth it. Fix your s*#t! 104 | Err(VarError::NotUnicode(_)) => bail!("Env variable {key} invalid"), 105 | } 106 | } 107 | 108 | /// Require an environment variable. 109 | fn getenv_path(key: &str) -> Result { 110 | match getenv_optional(key)? { 111 | Some(val) => Ok(PathBuf::from(val)), 112 | None => bail!("Env variable {key} unset"), 113 | } 114 | } 115 | 116 | /// Get details of *target* user; on error, formats a nice user-friendly message with instructions. 117 | fn get_target_user(username: &str) -> Result { 118 | if let Some(user) = User::from_name(username)? { 119 | return Ok(user); 120 | } 121 | 122 | debug!("Username '{username}' not found"); 123 | 124 | let mut hint = "Specify different user with --user= or create a new user".to_string(); 125 | 126 | // Find a free UID for a helpful error message. 127 | // UIDs >=1000 are visible on login screen, so better avoid them. 128 | // 129 | // https://refspecs.linuxfoundation.org/LSB_5.0.0/LSB-Core-generic/LSB-Core-generic/uidrange.html 130 | // > The system User IDs from 100 to 499 should be reserved for dynamic allocation by system 131 | // > administrators and post install scripts using useradd. 132 | for uid in 150..=499 { 133 | if User::from_uid(Uid::from_raw(uid))?.is_none() { 134 | hint = format!( 135 | "{hint} with the command:\n sudo useradd '{username}' --uid {uid} --create-home" 136 | ); 137 | break; 138 | } 139 | debug!("User UID {uid} already exists"); 140 | } 141 | 142 | Err(ErrorWithHint::new(format!("Unknown user '{username}'"), hint).into()) 143 | } 144 | 145 | fn create_context(username: &str) -> Result { 146 | let user = get_target_user(username)?; 147 | debug!( 148 | "Found user '{}' UID {} shell '{}'", 149 | user.name, 150 | user.uid, 151 | user.shell.display() 152 | ); 153 | let runtime_dir = getenv_path("XDG_RUNTIME_DIR")?; 154 | Ok(EgoContext { 155 | runtime_dir, 156 | target_user: user.name, 157 | target_uid: user.uid.as_raw(), 158 | target_user_shell: user.shell, 159 | target_user_homedir: user.dir, 160 | }) 161 | } 162 | 163 | fn add_file_acl(path: &Path, uid: u32, flags: u32) -> Result<(), AnyErr> { 164 | let mut acl = PosixACL::read_acl(path)?; 165 | acl.set(Qualifier::User(uid), flags); 166 | acl.write_acl(path)?; 167 | Ok(()) 168 | } 169 | 170 | /// Report warning if user home directory does not exist or has wrong ownership 171 | fn check_user_homedir(ctx: &EgoContext) { 172 | let home = &ctx.target_user_homedir; 173 | match fs::metadata(home) { 174 | Ok(meta) => { 175 | if meta.uid() != ctx.target_uid { 176 | warn!( 177 | "User {} home directory {} has incorrect ownership (expected UID {}, found {})", 178 | ctx.target_user, 179 | home.display(), 180 | ctx.target_uid, 181 | meta.uid() 182 | ); 183 | } 184 | } 185 | Err(err) => { 186 | // Report PermissionDenied as `info` level, user home directory is probably in a parent 187 | // directory we have no access to, avoid nagging. 188 | let level = match err.kind() { 189 | PermissionDenied => Level::Info, 190 | _ => Level::Warn, 191 | }; 192 | 193 | log!( 194 | level, 195 | "User {} home directory {} is not accessible: {err}", 196 | ctx.target_user, 197 | home.display(), 198 | ); 199 | } 200 | } 201 | } 202 | 203 | /// Add execute perm to runtime dir, e.g. `/run/user/1000` 204 | fn prepare_runtime_dir(ctx: &EgoContext) -> Result<(), AnyErr> { 205 | let path = &ctx.runtime_dir; 206 | if !path.is_dir() { 207 | bail!("'{}' is not a directory", path.display()); 208 | } 209 | add_file_acl(path, ctx.target_uid, ACL_EXECUTE)?; 210 | debug!("Runtime data dir '{}' configured", path.display()); 211 | Ok(()) 212 | } 213 | 214 | /// `WAYLAND_DISPLAY` may be absolute path or relative to `XDG_RUNTIME_DIR` 215 | /// See 216 | fn get_wayland_socket(ctx: &EgoContext) -> Result, AnyErr> { 217 | match getenv_optional("WAYLAND_DISPLAY")? { 218 | None => Ok(None), 219 | Some(display) => Ok(Some(ctx.runtime_dir.join(display))), 220 | } 221 | } 222 | 223 | /// Add rwx permissions to Wayland socket (e.g. `/run/user/1000/wayland-0`) 224 | /// Return environment vars for `WAYLAND_DISPLAY`. 225 | fn prepare_wayland(ctx: &EgoContext) -> Result, AnyErr> { 226 | let path = get_wayland_socket(ctx)?; 227 | if path.is_none() { 228 | debug!("Wayland: WAYLAND_DISPLAY not set, skipping"); 229 | return Ok(vec![]); 230 | } 231 | 232 | let path = path.unwrap(); 233 | add_file_acl(path.as_path(), ctx.target_uid, ACL_RWX)?; 234 | 235 | debug!("Wayland socket '{}' configured", path.display()); 236 | Ok(vec![format!("WAYLAND_DISPLAY={}", path.to_str().unwrap())]) 237 | } 238 | 239 | /// Detect `DISPLAY` and grant permissions via X11 protocol `ChangeHosts` command 240 | /// (or run `xhost` command if `--old-xhost` was used). 241 | /// Return environment vars for `DISPLAY` 242 | fn prepare_x11(ctx: &EgoContext, old_xhost: bool) -> Result, AnyErr> { 243 | let display = getenv_optional("DISPLAY")?; 244 | if display.is_none() { 245 | debug!("X11: DISPLAY not set, skipping"); 246 | return Ok(vec![]); 247 | } 248 | 249 | if old_xhost { 250 | warn!("--old-xhost is deprecated. If there are issues with the new method, please report a bug."); 251 | let grant = format!("+si:localuser:{}", ctx.target_user); 252 | run_command("xhost", &[grant])?; 253 | } else { 254 | x11_add_acl("localuser", &ctx.target_user)?; 255 | } 256 | // TODO should also test /tmp/.X11-unix/X0 permissions? 257 | 258 | Ok(vec![format!("DISPLAY={}", display.unwrap())]) 259 | } 260 | 261 | /// Add execute permissions to PulseAudio directory (e.g. `/run/user/1000/pulse`) 262 | /// Return environment vars for `PULSE_SERVER`. 263 | /// 264 | /// The actual socket `/run/user/1000/pulse/native` already has full read-write permissions. 265 | fn prepare_pulseaudio(ctx: &EgoContext) -> Result, AnyErr> { 266 | let path = ctx.runtime_dir.join("pulse"); 267 | if !path.is_dir() { 268 | debug!("PulseAudio dir '{}' not found, skipping", path.display()); 269 | return Ok(vec![]); 270 | } 271 | add_file_acl(path.as_path(), ctx.target_uid, ACL_EXECUTE)?; 272 | 273 | let mut envs = prepare_pulseaudio_socket(path.as_path())?; 274 | envs.extend(prepare_pulseaudio_cookie(ctx)?); 275 | 276 | debug!("PulseAudio dir '{}' configured", path.display()); 277 | Ok(envs) 278 | } 279 | 280 | /// Ensure permissions of PulseAudio socket `/run/user/1000/pulse/native` 281 | fn prepare_pulseaudio_socket(dir: &Path) -> Result, AnyErr> { 282 | let path = dir.join("native"); 283 | let meta = path.metadata(); 284 | if let Err(msg) = meta { 285 | bail!("'{}': {msg}", path.display()); 286 | } 287 | let mode = meta.unwrap().permissions().mode(); 288 | 289 | #[allow(clippy::items_after_statements)] 290 | const WORLD_READ_PERMS: u32 = 0o006; 291 | if mode & WORLD_READ_PERMS != WORLD_READ_PERMS { 292 | bail!( 293 | "Unexpected permissions on '{}': {:o}", 294 | path.display(), 295 | mode & 0o777 296 | ); 297 | } 298 | Ok(vec![format!( 299 | "PULSE_SERVER=unix:{}", 300 | path.to_str().unwrap() 301 | )]) 302 | } 303 | 304 | /// Try various ways to discover the current user's PulseAudio authentication cookie. 305 | fn find_pulseaudio_cookie() -> Result { 306 | // Try PULSE_COOKIE 307 | if let Some(path) = getenv_optional("PULSE_COOKIE")? { 308 | return Ok(PathBuf::from(path)); 309 | } 310 | // Try ~/.config/pulse/cookie 311 | let home = getenv_path("HOME")?; 312 | let path = home.join(".config/pulse/cookie"); 313 | if path.is_file() { 314 | return Ok(path); 315 | } 316 | 317 | // Try ~/.pulse-cookie, for older PulseAudio versions 318 | let path = home.join(".pulse-cookie"); 319 | if path.is_file() { 320 | return Ok(path); 321 | } 322 | 323 | bail!( 324 | "Cannot locate PulseAudio cookie \ 325 | (tried $PULSE_COOKIE, ~/.config/pulse/cookie, ~/.pulse-cookie)" 326 | ) 327 | } 328 | 329 | /// Publish current user's pulse-cookie for target user 330 | fn prepare_pulseaudio_cookie(ctx: &EgoContext) -> Result, AnyErr> { 331 | let cookie_path = find_pulseaudio_cookie()?; 332 | let target_path = ensure_ego_rundir(ctx)?.join("pulse-cookie"); 333 | debug!( 334 | "Publishing PulseAudio cookie {} to {}", 335 | cookie_path.display(), 336 | target_path.display() 337 | ); 338 | fs::copy(cookie_path.as_path(), target_path.as_path())?; 339 | add_file_acl(target_path.as_path(), ctx.target_uid, ACL_READ)?; 340 | 341 | Ok(vec![format!( 342 | "PULSE_COOKIE={}", 343 | target_path.to_str().unwrap() 344 | )]) 345 | } 346 | 347 | /// Create runtime dir for Ego itself (e.g. `/run/user/1000/ego`) and make it readable for target 348 | /// user. This directory us used to share state (e.g. PulseAudio auth cookie). 349 | fn ensure_ego_rundir(ctx: &EgoContext) -> Result { 350 | // XXX We assume that prepare_runtime_dir() has already been called. 351 | let path = ctx.runtime_dir.join("ego"); 352 | if !path.is_dir() { 353 | DirBuilder::new().mode(0o700).create(path.as_path())?; 354 | } 355 | // Set ACL either way, because target user may be different in every run. 356 | add_file_acl(path.as_path(), ctx.target_uid, ACL_EXECUTE)?; 357 | Ok(path) 358 | } 359 | 360 | /// Detect which method should be used 361 | fn detect_method() -> Method { 362 | if !sd_booted() { 363 | return Method::Sudo; 364 | } 365 | if !have_command("machinectl") { 366 | // If booted using systemd, issue a warning 367 | warn!("machinectl (systemd-container) is not installed"); 368 | warn!("Falling back to 'sudo', some desktop integration features may not work"); 369 | return Method::Sudo; 370 | } 371 | Method::Machinectl 372 | } 373 | 374 | fn run_sudo_command( 375 | ctx: &EgoContext, 376 | envvars: Vec, 377 | remote_cmd: Vec, 378 | ) -> Result<(), AnyErr> { 379 | if !remote_cmd.is_empty() && remote_cmd[0].starts_with('-') { 380 | bail!( 381 | "Command may not start with '-' (command is: '{}')", 382 | remote_cmd[0] 383 | ); 384 | } 385 | 386 | let mut args = vec!["-Hiu".to_string(), ctx.target_user.clone()]; 387 | // If SUDO_ASKPASS envvar is set, add -A argument to use the askpass agent 388 | if let Ok(Some(_)) = getenv_optional("SUDO_ASKPASS") { 389 | debug!("SUDO_ASKPASS detected"); 390 | args.push("-A".into()); 391 | } 392 | args.extend(envvars); 393 | args.extend(remote_cmd); 394 | 395 | info!("Running command: sudo {}", args.join(" ")); 396 | exec_command("sudo", &args)?; 397 | Ok(()) 398 | } 399 | 400 | #[allow(clippy::format_push_string)] 401 | fn machinectl_remote_command(remote_cmd: Vec, envvars: &[String], bare: bool) -> String { 402 | let mut cmd = String::new(); 403 | 404 | if !bare { 405 | // Split env variables by '=', to pass just their names 406 | let env_names = envvars 407 | .iter() 408 | .map(|v| v.split('=').next().expect("Unexpected data in envvars")); 409 | 410 | // Set environment variables in systemd 411 | cmd.push_str(&format!( 412 | "dbus-update-activation-environment --systemd {}; ", 413 | shell_words::join(env_names) 414 | )); 415 | // TODO: Should we support desktop-portals other than gtk? 416 | // XXX what happens if the desktop-portal is already running but with an outdated environment? 417 | cmd.push_str("systemctl --user start xdg-desktop-portal-gtk; "); 418 | } 419 | cmd.push_str(&format!("exec {}", shell_words::join(remote_cmd))); 420 | cmd 421 | } 422 | 423 | fn run_machinectl_command( 424 | ctx: &EgoContext, 425 | envvars: &[String], 426 | remote_cmd: Vec, 427 | bare: bool, 428 | ) -> Result<(), AnyErr> { 429 | let mut args = vec!["shell".to_string()]; 430 | args.push(format!("--uid={}", ctx.target_user)); 431 | args.extend(envvars.iter().map(|v| format!("-E{v}"))); 432 | args.push("--".to_string()); 433 | args.push(".host".to_string()); 434 | 435 | // I wish this could be done without going through /bin/sh, but seems necessary. 436 | args.push("/bin/sh".to_string()); 437 | args.push("-c".to_string()); 438 | let remote_cmd = if remote_cmd.is_empty() { 439 | vec![require_with!( 440 | ctx.target_user_shell.to_str(), 441 | "User '{}' shell has unexpected characters", 442 | ctx.target_user 443 | ) 444 | .to_string()] 445 | } else { 446 | remote_cmd 447 | }; 448 | args.push(machinectl_remote_command(remote_cmd, envvars, bare)); 449 | 450 | info!("Running command: machinectl {}", shell_words::join(&args)); 451 | exec_command("machinectl", &args)?; 452 | Ok(()) 453 | } 454 | -------------------------------------------------------------------------------- /src/snapshots/check_user_homedir.txt: -------------------------------------------------------------------------------- 1 | INFO: TEST: Success (no output) 2 | INFO: TEST: Home does not exist 3 | WARN: User nope home directory /tmp/path-does-not-exist.example is not accessible: No such file or directory (os error 2) 4 | INFO: TEST: Permission denied 5 | INFO: User root home directory /root/path-is-not-accessible.example is not accessible: Permission denied (os error 13) 6 | INFO: TEST: Wrong owner 7 | WARN: User root home directory /root has incorrect ownership (expected UID 1234, found 0) 8 | -------------------------------------------------------------------------------- /src/snapshots/ego.help: -------------------------------------------------------------------------------- 1 | Alter Ego: run Linux desktop applications under a different local user 2 | 3 | Usage: ego [OPTIONS] [command]... 4 | 5 | Arguments: 6 | [command]... Command name and arguments to run (default: user shell) 7 | 8 | Options: 9 | -u, --user Specify a username (default: ego) [default: ego] 10 | --sudo Use 'sudo' to change user 11 | --machinectl Use 'machinectl' to change user (default, if available) 12 | --machinectl-bare Use 'machinectl' but skip xdg-desktop-portal setup 13 | --old-xhost Execute 'xhost' command instead of connecting to X11 directly 14 | -v, --verbose... Verbose output. Use multiple times for more output. 15 | -h, --help Print help 16 | -V, --version Print version 17 | -------------------------------------------------------------------------------- /src/tests.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fmt::Write; 3 | use std::path::PathBuf; 4 | use std::sync::OnceLock; 5 | 6 | use clap_complete::shells::{Bash, Fish, Zsh}; 7 | use clap_complete::Generator; 8 | use log::{info, Level}; 9 | use snapbox::Assert; 10 | use snapbox::{file, Data}; 11 | 12 | use crate::cli::{build_cli, parse_args, Method}; 13 | use crate::util::have_command; 14 | use crate::x11::x11_add_acl; 15 | use crate::{check_user_homedir, get_wayland_socket, EgoContext}; 16 | 17 | /// `vec![]` constructor that converts arguments to String 18 | macro_rules! string_vec { 19 | ($($x:expr),*) => (vec![$($x.to_string()),*] as Vec); 20 | } 21 | 22 | fn snapshot() -> &'static Assert { 23 | static SNAPSHOT: OnceLock = OnceLock::new(); 24 | SNAPSHOT.get_or_init(|| Assert::new().action_env("SNAPSHOTS")) 25 | } 26 | 27 | /// Compare log output with snapshot file. Call `testing_logger::setup()` at beginning of test. 28 | fn assert_log_snapshot(expected_path: &Data) { 29 | testing_logger::validate(|logs| { 30 | let output = logs.iter().fold(String::new(), |mut a, b| { 31 | writeln!(a, "{}: {}", b.level.as_str(), b.body).unwrap(); 32 | a 33 | }); 34 | snapshot().eq(output, expected_path); 35 | }); 36 | } 37 | 38 | fn render_completion(generator: impl Generator) -> Data { 39 | let mut buf = Vec::::new(); 40 | let mut app = build_cli(); 41 | clap_complete::generate(generator, &mut app, "ego", &mut buf); 42 | buf.into() 43 | } 44 | 45 | /// Unit tests may seem like a weird place to update shell completion files, but snapshot testing 46 | /// guarantees the files are never out of date. 47 | /// 48 | /// Also we don't have to lug around `clap_complete` code in the `ego` binary itself. 49 | /// 50 | /// Run `SNAPSHOTS=overwrite cargo test` to update 51 | /// 52 | /// Usage with zsh: 53 | /// ``` 54 | /// cp varia/ego-completion.zsh /usr/local/share/zsh/site-functions/_ego 55 | /// ``` 56 | #[test] 57 | fn shell_completion_zsh() { 58 | snapshot().eq( 59 | render_completion(Zsh), 60 | file!["../varia/ego-completion.zsh"].raw(), 61 | ); 62 | } 63 | 64 | /// Run `SNAPSHOTS=overwrite cargo test` to update 65 | #[test] 66 | fn shell_completion_bash() { 67 | snapshot().eq( 68 | render_completion(Bash), 69 | file!["../varia/ego-completion.bash"], 70 | ); 71 | } 72 | 73 | /// Run `SNAPSHOTS=overwrite cargo test` to update 74 | #[test] 75 | fn shell_completion_fish() { 76 | snapshot().eq( 77 | render_completion(Fish), 78 | file!["../varia/ego-completion.fish"].raw(), 79 | ); 80 | } 81 | 82 | fn test_context() -> EgoContext { 83 | EgoContext { 84 | runtime_dir: "/run/user/1000".into(), 85 | target_user: "ego".into(), 86 | target_uid: 155, 87 | target_user_shell: "/bin/bash".into(), 88 | target_user_homedir: "/home/ego".into(), 89 | } 90 | } 91 | 92 | #[test] 93 | fn wayland_socket() { 94 | let ctx = test_context(); 95 | env::remove_var("WAYLAND_DISPLAY"); 96 | assert_eq!(get_wayland_socket(&ctx).unwrap(), None); 97 | 98 | env::set_var("WAYLAND_DISPLAY", "wayland-7"); 99 | assert_eq!( 100 | get_wayland_socket(&ctx).unwrap().unwrap(), 101 | PathBuf::from("/run/user/1000/wayland-7") 102 | ); 103 | 104 | env::set_var("WAYLAND_DISPLAY", "/tmp/wayland-7"); 105 | assert_eq!( 106 | get_wayland_socket(&ctx).unwrap().unwrap(), 107 | PathBuf::from("/tmp/wayland-7") 108 | ); 109 | } 110 | 111 | #[test] 112 | fn test_x11_error() { 113 | env::remove_var("DISPLAY"); 114 | 115 | let err = x11_add_acl("test", "test").unwrap_err(); 116 | assert_eq!( 117 | err.to_string(), 118 | "Connection closed, error during parsing display string" 119 | ); 120 | } 121 | 122 | #[test] 123 | fn test_cli() { 124 | build_cli().debug_assert(); 125 | } 126 | 127 | #[test] 128 | fn test_parse_args() { 129 | // Empty command line (defaults) 130 | let args = parse_args(vec!["ego"]); 131 | assert_eq!(args.user, "ego".to_string()); 132 | assert_eq!(args.command, string_vec![]); 133 | assert_eq!(args.log_level, Level::Warn); 134 | assert_eq!(args.method, None); 135 | 136 | // --user 137 | assert_eq!( 138 | parse_args(vec!["ego", "-u", "myself"]).user, 139 | "myself".to_string() 140 | ); 141 | // command with -flags 142 | assert_eq!( 143 | parse_args(vec!["ego", "ls", "-la"]).command, 144 | string_vec!["ls", "-la"] 145 | ); 146 | // verbosity 147 | assert_eq!(parse_args(vec!["ego", "-v"]).log_level, Level::Info); 148 | assert_eq!(parse_args(vec!["ego", "-v", "-v"]).log_level, Level::Debug); 149 | assert_eq!(parse_args(vec!["ego", "-vvvvvv"]).log_level, Level::Trace); 150 | // --machinectl 151 | assert_eq!( 152 | parse_args(vec!["ego", "--machinectl"]).method, 153 | Some(Method::Machinectl) 154 | ); 155 | } 156 | 157 | #[test] 158 | fn test_cli_help() { 159 | snapshot().eq( 160 | build_cli().render_help().to_string(), 161 | file!["snapshots/ego.help"], 162 | ); 163 | } 164 | 165 | #[test] 166 | fn test_have_command() { 167 | assert!(have_command("sh")); 168 | assert!(!have_command("what-is-this-i-don't-even")); 169 | } 170 | 171 | #[test] 172 | fn test_check_user_homedir() { 173 | let ctx = EgoContext { 174 | runtime_dir: PathBuf::default(), 175 | target_user: "root".to_string(), 176 | target_uid: 0, 177 | target_user_shell: PathBuf::default(), 178 | target_user_homedir: "/root".into(), 179 | }; 180 | 181 | // Capture log output from called functions 182 | testing_logger::setup(); 183 | 184 | info!("TEST: Success (no output)"); 185 | check_user_homedir(&ctx); 186 | 187 | info!("TEST: Home does not exist"); 188 | check_user_homedir(&EgoContext { 189 | target_user: "nope".into(), 190 | target_user_homedir: "/tmp/path-does-not-exist.example".into(), 191 | ..ctx.clone() 192 | }); 193 | 194 | info!("TEST: Permission denied"); 195 | check_user_homedir(&EgoContext { 196 | target_user_homedir: "/root/path-is-not-accessible.example".into(), 197 | ..ctx.clone() 198 | }); 199 | 200 | info!("TEST: Wrong owner"); 201 | check_user_homedir(&EgoContext { 202 | target_uid: 1234, 203 | ..ctx.clone() 204 | }); 205 | 206 | assert_log_snapshot(&file!["snapshots/check_user_homedir.txt"]); 207 | } 208 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::ErrorWithHint; 2 | use anstyle::Style; 3 | use log::debug; 4 | use std::fmt::Display; 5 | use std::io::ErrorKind; 6 | use std::os::unix::prelude::CommandExt; 7 | use std::path::Path; 8 | use std::process::{Command, Output}; 9 | use std::{env, io}; 10 | 11 | /// Paint string `content` with ANSI colors `style` for printing to console. 12 | pub fn paint(style: Style, content: impl Display) -> String { 13 | format!("{}{content}{}", style.render(), style.render_reset()) 14 | } 15 | 16 | /// Detect if system was booted with systemd init system. Same logic as `sd_booted()` in libsystemd. 17 | /// 18 | pub fn sd_booted() -> bool { 19 | Path::new("/run/systemd/system").exists() 20 | } 21 | 22 | /// Test if a command is present in `$PATH` 23 | /// Adapted from 24 | pub fn have_command>(exe_name: P) -> bool { 25 | env::var_os("PATH") 26 | .is_some_and(|paths| env::split_paths(&paths).any(|dir| dir.join(&exe_name).is_file())) 27 | } 28 | 29 | fn report_command_error(err: &io::Error, program: &str, args: &[String]) -> ErrorWithHint { 30 | ErrorWithHint::new( 31 | format!("Failed to run {program}: {err}"), 32 | if err.kind() == ErrorKind::NotFound { 33 | format!("Try installing package that contains command '{program}'") 34 | } else { 35 | format!("Complete command: {program} {}", shell_words::join(args)) 36 | }, 37 | ) 38 | } 39 | 40 | /// Exec command (ending the current process) or return error. 41 | pub fn exec_command(program: &str, args: &[String]) -> Result<(), ErrorWithHint> { 42 | debug!("Executing: {program} {}", shell_words::join(args)); 43 | // If this call returns at all, it was an error 44 | let err = Command::new(program).args(args).exec(); 45 | 46 | Err(report_command_error(&err, program, args)) 47 | } 48 | 49 | /// Run command as subprocess. Return output if status was 0, otherwise return as error. 50 | pub fn run_command(program: &str, args: &[String]) -> Result { 51 | debug!("Running: {program} {}", shell_words::join(args)); 52 | let ret = Command::new(program) 53 | .args(args) 54 | .output() 55 | .map_err(|err| report_command_error(&err, program, args))?; 56 | 57 | if !ret.status.success() { 58 | return Err(ErrorWithHint::new( 59 | format!( 60 | "{program} returned {}:\n{}", 61 | ret.status.code().unwrap_or(999), 62 | String::from_utf8_lossy(&ret.stderr).trim() 63 | ), 64 | format!("Complete command: {program} {}", shell_words::join(args)), 65 | )); 66 | } 67 | Ok(ret) 68 | } 69 | -------------------------------------------------------------------------------- /src/x11.rs: -------------------------------------------------------------------------------- 1 | use log::debug; 2 | use xcb::x::{ChangeHosts, Family, HostMode}; 3 | use xcb::Connection; 4 | 5 | use crate::errors::AnyErr; 6 | 7 | pub fn x11_add_acl(type_tag: &str, value: &str) -> Result<(), AnyErr> { 8 | let (conn, _screen_num) = Connection::connect(None)?; 9 | 10 | debug!("X11: Adding XHost entry SI:{type_tag}:{value}"); 11 | 12 | let result = conn.send_and_check_request(&ChangeHosts { 13 | mode: HostMode::Insert, 14 | family: Family::ServerInterpreted, 15 | address: format!("{type_tag}\x00{value}").as_bytes(), 16 | }); 17 | map_err_with!(result, "Error adding XHost entry")?; 18 | 19 | Ok(()) 20 | } 21 | -------------------------------------------------------------------------------- /varia/Dockerfile.tests: -------------------------------------------------------------------------------- 1 | # This Dockerfile is mostly for CI, see .github/workflows/tests.yml 2 | FROM rust AS ego-build 3 | 4 | WORKDIR /root/build 5 | # Make warnings fatal 6 | ENV RUSTFLAGS="-D warnings" 7 | 8 | RUN apt-get update && \ 9 | apt-get install -y libacl1-dev && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | # Build as unprivileged user 13 | RUN useradd build --create-home 14 | WORKDIR /home/build 15 | USER build 16 | 17 | RUN rustup component add rustfmt clippy 18 | 19 | # Build Cargo dependencies for cache 20 | COPY Cargo.toml Cargo.lock ./ 21 | RUN mkdir src/ && \ 22 | echo "pub fn main() {println!(\"dummy function\")}" > src/main.rs && \ 23 | cargo build --bins --tests --color=always && \ 24 | rm -rdv target/*/deps/ego-* \ 25 | target/*/.fingerprint/ego-* 26 | 27 | # Do the actual build 28 | COPY . . 29 | RUN cargo build --bins --tests --color=always 30 | -------------------------------------------------------------------------------- /varia/README.md: -------------------------------------------------------------------------------- 1 | Shell completion 2 | ---------------- 3 | For shell completions to work, these files should be installed as: 4 | 5 | * `ego-completion.zsh` → `/usr/share/zsh/site-functions/_ego` 6 | * `ego-completion.bash` → `/usr/share/bash-completion/completions/ego` 7 | * `ego-completion.fish` → `/usr/share/fish/vendor_completions.d/ego.fish` 8 | 9 | These files are auto-generated with `clap_generate`. To update them, run 10 | `SNAPSHOTS=overwrite cargo test` 11 | 12 | Packaging ego 13 | ------------- 14 | The following files are helpful for distribution packagers, so ego can work seamlessly out of the box. 15 | 16 | Distro packages should auto-create the `ego` user with low UID (<1000) and home `/home/ego`. 17 | And a separate group `ego-users` for users that are allowed to invoke commands as `ego`. 18 | 19 | The `ego.sysusers.conf` and `ego.tmpfiles.conf` drop-in files should create them on distros that 20 | The `ego.sysusers.conf` and `ego.tmpfiles.conf` drop-in files should create them on distros that 21 | support sysusers.d and tmpfiles.d. The sudoers and polkit rules files then permit switching users. 22 | 23 | * `ego.sysusers.conf` → `/usr/lib/sysusers.d/ego.conf` 24 | * `ego.tmpfiles.conf` → `/usr/lib/tmpfiles.d/ego.conf` 25 | * `ego.sudoers` → `/etc/sudoers.d/50_ego` 26 | * `ego.rules` → `/usr/share/polkit-1/rules.d/50-ego.rules` 27 | 28 | Note: `ego.rules` requires systemd version >=247 and polkit >=0.106. 29 | -------------------------------------------------------------------------------- /varia/ego-completion.bash: -------------------------------------------------------------------------------- 1 | _ego() { 2 | local i cur prev opts cmd 3 | COMPREPLY=() 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | cmd="" 7 | opts="" 8 | 9 | for i in ${COMP_WORDS[@]} 10 | do 11 | case "${cmd},${i}" in 12 | ",$1") 13 | cmd="ego" 14 | ;; 15 | *) 16 | ;; 17 | esac 18 | done 19 | 20 | case "${cmd}" in 21 | ego) 22 | opts="-u -v -h -V --user --sudo --machinectl --machinectl-bare --old-xhost --verbose --help --version [command]..." 23 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 24 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 25 | return 0 26 | fi 27 | case "${prev}" in 28 | --user) 29 | COMPREPLY=($(compgen -f "${cur}")) 30 | return 0 31 | ;; 32 | -u) 33 | COMPREPLY=($(compgen -f "${cur}")) 34 | return 0 35 | ;; 36 | *) 37 | COMPREPLY=() 38 | ;; 39 | esac 40 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 41 | return 0 42 | ;; 43 | esac 44 | } 45 | 46 | if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then 47 | complete -F _ego -o nosort -o bashdefault -o default ego 48 | else 49 | complete -F _ego -o bashdefault -o default ego 50 | fi 51 | -------------------------------------------------------------------------------- /varia/ego-completion.fish: -------------------------------------------------------------------------------- 1 | complete -c ego -s u -l user -d 'Specify a username (default: ego)' -r -f -a "(__fish_complete_users)" 2 | complete -c ego -l sudo -d 'Use \'sudo\' to change user' 3 | complete -c ego -l machinectl -d 'Use \'machinectl\' to change user (default, if available)' 4 | complete -c ego -l machinectl-bare -d 'Use \'machinectl\' but skip xdg-desktop-portal setup' 5 | complete -c ego -l old-xhost -d 'Execute \'xhost\' command instead of connecting to X11 directly' 6 | complete -c ego -s v -l verbose -d 'Verbose output. Use multiple times for more output.' 7 | complete -c ego -s h -l help -d 'Print help' 8 | complete -c ego -s V -l version -d 'Print version' 9 | -------------------------------------------------------------------------------- /varia/ego-completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef ego 2 | 3 | autoload -U is-at-least 4 | 5 | _ego() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" : \ 18 | '-u+[Specify a username (default\: ego)]:USER:_users' \ 19 | '--user=[Specify a username (default\: ego)]:USER:_users' \ 20 | '--sudo[Use '\''sudo'\'' to change user]' \ 21 | '--machinectl[Use '\''machinectl'\'' to change user (default, if available)]' \ 22 | '--machinectl-bare[Use '\''machinectl'\'' but skip xdg-desktop-portal setup]' \ 23 | '--old-xhost[Execute '\''xhost'\'' command instead of connecting to X11 directly]' \ 24 | '*-v[Verbose output. Use multiple times for more output.]' \ 25 | '*--verbose[Verbose output. Use multiple times for more output.]' \ 26 | '-h[Print help]' \ 27 | '--help[Print help]' \ 28 | '-V[Print version]' \ 29 | '--version[Print version]' \ 30 | '*::command -- Command name and arguments to run (default\: user shell):_cmdambivalent' \ 31 | && ret=0 32 | } 33 | 34 | (( $+functions[_ego_commands] )) || 35 | _ego_commands() { 36 | local commands; commands=() 37 | _describe -t commands 'ego commands' commands "$@" 38 | } 39 | 40 | if [ "$funcstack[1]" = "_ego" ]; then 41 | _ego "$@" 42 | else 43 | compdef _ego ego 44 | fi 45 | -------------------------------------------------------------------------------- /varia/ego.rules: -------------------------------------------------------------------------------- 1 | /* 2 | * Alter Ego: run desktop applications under a different local user 3 | * Users in 'ego-users' group can invoke commands as 'ego' user 4 | */ 5 | polkit.addRule(function(action, subject) { 6 | if (action.id == "org.freedesktop.machine1.host-shell" && 7 | action.lookup("user") == "ego" && 8 | subject.isInGroup("ego-users")) { 9 | return polkit.Result.YES; 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /varia/ego.sudoers: -------------------------------------------------------------------------------- 1 | # Alter Ego: run desktop applications under a different local user 2 | # Users in 'ego-users' group can invoke commands as 'ego' user 3 | %ego-users ALL=(ego) NOPASSWD:ALL 4 | -------------------------------------------------------------------------------- /varia/ego.sysusers.conf: -------------------------------------------------------------------------------- 1 | # Alter Ego: run desktop applications under a different local user 2 | # Users in 'ego-users' group can invoke commands as 'ego' user 3 | #Type Name ID GECOS Home directory Shell 4 | u ego - "Alter Ego" /home/ego /bin/bash 5 | g ego-users - 6 | -------------------------------------------------------------------------------- /varia/ego.tmpfiles.conf: -------------------------------------------------------------------------------- 1 | # Alter Ego: run desktop applications under a different local user 2 | d /home/ego 0700 ego ego - 3 | --------------------------------------------------------------------------------