diff --git a/build.rs b/build.rs index ad6ff7d..677a1e9 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,5 @@ fn main() { - let lib_path = "./assets/libs/arm64-v8a"; - println!("cargo::rustc-link-search={}", lib_path); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") { + println!("cargo::rustc-link-search=./assets/libs/arm64-v8a"); + } } diff --git a/gh-pages/docs/user/2-creating-a-non-root-user.md b/gh-pages/docs/user/2-creating-a-non-root-user.md index ab4df27..5bdd6a0 100644 --- a/gh-pages/docs/user/2-creating-a-non-root-user.md +++ b/gh-pages/docs/user/2-creating-a-non-root-user.md @@ -61,13 +61,17 @@ _(Replace `teddy` with the username you created [previously](#create-your-user)) Save and exit. -You can test if your new user can use `sudo` by temporarily logging in: +You can test whether the account is resolvable and can use `sudo` by temporarily logging in: ```bash -su teddy # Change to your username +getent passwd teddy # Change to your username +su - teddy sudo ls /root # Make sure it does not output something like: "teddy is not in the sudoers file" +exit ``` +If `su` displays `I have no name`, close and reopen Local Desktop, then repeat the test. Local Desktop repairs the account database permissions during startup so non-root processes can resolve users and groups. + ## [Important] Tell Local Desktop You must tell Local Desktop who to log in as, or it will log in as root. To (create and) edit the config file: @@ -76,13 +80,14 @@ You must tell Local Desktop who to log in as, or it will log in as root. To (cre nano /etc/localdesktop/localdesktop.toml ``` -Add the following content: +Test the new account for one launch while keeping root as the persistent fallback: ```toml title="/etc/localdesktop/localdesktop.toml" [user] -username = "teddy" +username = "root" +try_username = "teddy" ``` _(Replace `teddy` with the username you created [previously](#create-your-user))_ -The changes will take effect the next time you launch Local Desktop. If something goes wrong, you can always delete this config file and restart as root. +The changes will take effect the next time you launch Local Desktop. After that launch, `try_username` is commented out automatically. If the desktop works, change `username = "root"` to `username = "teddy"` and remove the commented `try_username` line. If the configured account is missing, Local Desktop falls back to root so you can repair the account or config without clearing the app's storage. diff --git a/src/android/proot/process.rs b/src/android/proot/process.rs index 51b2845..42f7b17 100644 --- a/src/android/proot/process.rs +++ b/src/android/proot/process.rs @@ -1,5 +1,8 @@ use crate::android::utils::application_context::get_application_context; -use crate::core::config; +use crate::core::{ + config, + guest_user::{repair_account_database_permissions, GuestUser, ROOT_USERNAME}, +}; use std::ffi::CString; use std::fs; use std::io::{BufRead, BufReader, Read}; @@ -101,7 +104,17 @@ impl ArchProcess { pub fn run(self) -> Output { let context = get_application_context(); - let user = self.user.as_deref().unwrap_or("root"); + if let Err(error) = repair_account_database_permissions(Path::new(config::ARCH_FS_ROOT)) { + log::warn!("Failed to repair guest account database permissions: {error}"); + } + + let requested_username = self.user.as_deref().unwrap_or(ROOT_USERNAME); + let user = GuestUser::resolve(Path::new(config::ARCH_FS_ROOT), requested_username); + if self.user.is_some() && user.username() != requested_username { + log::warn!( + "Configured guest user '{requested_username}' could not be resolved; falling back to root" + ); + } let mut process = Command::new(context.native_library_dir.join("libproot.so")); process @@ -153,28 +166,24 @@ impl ArchProcess { // env vars process.arg("/usr/bin/env").arg("-i"); - if user == "root" { - process.arg("HOME=/root"); - } else { - process.arg(format!("HOME=/home/{}", user)); - } process + .arg(format!("HOME={}", user.guest_home_dir().display())) .arg("LANG=C.UTF-8") .arg("TERM=xterm-256color") .arg("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/usr/games:/system/bin:/system/xbin") .arg("TMPDIR=/tmp") - .arg(format!("USER={}", user)) - .arg(format!("LOGNAME={}", user)); + .arg(format!("USER={}", user.username())) + .arg(format!("LOGNAME={}", user.username())); // user shell - if user == "root" { + if user.username() == ROOT_USERNAME { process.arg("sh"); } else { process .arg("runuser") .arg("--pty") .arg("-u") - .arg(user) + .arg(user.username()) .arg("--") .arg("sh"); } diff --git a/src/android/proot/setup.rs b/src/android/proot/setup.rs index 1b371e1..9a5da73 100644 --- a/src/android/proot/setup.rs +++ b/src/android/proot/setup.rs @@ -9,7 +9,10 @@ use crate::{ utils::application_context::get_application_context, utils::ndk::run_in_jvm, }, - core::config::{CommandConfig, ARCH_FS_ARCHIVE, ARCH_FS_ROOT, DOCS_HOME_URL, PULSE_GUEST_SERVER}, + core::{ + config::{CommandConfig, ARCH_FS_ARCHIVE, ARCH_FS_ROOT, DOCS_HOME_URL, PULSE_GUEST_SERVER}, + guest_user::GuestUser, + }, }; use jni::objects::JObject; use jni::sys::_jobject; @@ -19,7 +22,7 @@ use std::{ fs::{self, File}, io::{Read, Write}, os::unix::fs::{symlink, PermissionsExt}, - path::{Path, PathBuf}, + path::Path, sync::{ mpsc::{self, Sender}, Arc, Mutex, @@ -501,14 +504,6 @@ runpy.run_path('/usr/sbin/onboard', run_name='__main__') None } -fn chroot_home_dir(fs_root: &Path, username: &str) -> PathBuf { - if username == "root" { - fs_root.join("root") - } else { - fs_root.join(format!("home/{username}")) - } -} - fn write_executable(path: &Path, contents: &str) { if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); @@ -561,8 +556,9 @@ fn android_ui_scale(density_dpi: i32) -> i32 { fn setup_xfce_wayland(options: &SetupOptions) -> StageOutput { let fs_root = Path::new(ARCH_FS_ROOT); - let username = get_application_context().local_config.user.username; - let home_dir = chroot_home_dir(fs_root, &username); + let configured_username = get_application_context().local_config.user.username; + let guest_user = GuestUser::resolve(fs_root, &configured_username); + let home_dir = guest_user.host_home_dir(fs_root); let labwc_dir = home_dir.join(".config/xfce4/labwc"); let density_dpi = read_android_density_dpi(options.android_app.clone()); @@ -926,7 +922,7 @@ pub fn setup(android_app: AndroidApp) -> PolarBearBackend { Box::new(setup_onboard_signal_fix), // Step 6. Wrap Onboard to survive proot fstat/signal.set_wakeup_fd failure Box::new(setup_xfce_wayland), // Step 7. Setup Xfce Wayland launch and HiDPI scaling Box::new(fix_xkb_symlink), // Step 8. Fix xkb symlink - Box::new(setup_pulse_client_conf), // Step 9. Write PulseAudio conf (last) + Box::new(setup_pulse_client_conf), // Step 9. Write PulseAudio conf (last) ]; let handle_stage_error = |e: Box, sender: &Sender| { diff --git a/src/core/config.rs b/src/core/config.rs index 926cdef..0d2dc9a 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -41,7 +41,7 @@ pub const CONFIG_FILE: &str = "/etc/localdesktop/localdesktop.toml"; #[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct LocalConfig { - #[serde(default)] + #[serde(default, alias = "users")] pub user: UserConfig, /// What happens if we don't assign this `#[serde(default)]` attribute? @@ -54,13 +54,18 @@ pub struct LocalConfig { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserConfig { + #[serde(default = "default_username")] pub username: String, } +fn default_username() -> String { + "root".to_string() +} + impl Default for UserConfig { fn default() -> Self { Self { - username: "root".to_string(), + username: default_username(), } } } @@ -179,8 +184,7 @@ pub fn parse_config(full_config_path: String) -> LocalConfig { return config; } // Config malformed, use the default config and the user can modify it again - let default_config = LocalConfig::default(); - default_config + LocalConfig::default() } #[cfg(test)] @@ -244,6 +248,41 @@ mod tests { ); } + #[test] + fn should_keep_other_sections_when_try_username_has_been_consumed() { + with_config_file( + r#" + [user] + try_username = "alice" + + [command] + check = "custom-check" + "#, + |full_config_path| { + let first_launch = parse_config(full_config_path.clone()); + assert_eq!(first_launch.user.username, "alice"); + + let second_launch = parse_config(full_config_path); + assert_eq!(second_launch.user.username, "root"); + assert_eq!(second_launch.command.check, "custom-check"); + }, + ); + } + + #[test] + fn should_accept_the_legacy_users_section_name() { + with_config_file( + r#" + [users] + username = "alice" + "#, + |full_config_path| { + let config = parse_config(full_config_path); + assert_eq!(config.user.username, "alice"); + }, + ); + } + #[test] fn should_comment_out_try_configs() { with_config_file( diff --git a/src/core/guest_user.rs b/src/core/guest_user.rs new file mode 100644 index 0000000..e6c42ae --- /dev/null +++ b/src/core/guest_user.rs @@ -0,0 +1,193 @@ +use std::{ + fs, io, + path::{Component, Path, PathBuf}, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +pub const ROOT_USERNAME: &str = "root"; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct GuestUser { + username: String, + home_dir: PathBuf, +} + +impl GuestUser { + pub fn resolve(fs_root: &Path, requested_username: &str) -> Self { + find_in_passwd(fs_root, requested_username) + .or_else(|| find_in_passwd(fs_root, ROOT_USERNAME)) + .unwrap_or_else(Self::root) + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn guest_home_dir(&self) -> &Path { + &self.home_dir + } + + pub fn host_home_dir(&self, fs_root: &Path) -> PathBuf { + fs_root.join( + self.home_dir + .strip_prefix("/") + .expect("guest home directory must be absolute"), + ) + } + + fn root() -> Self { + Self { + username: ROOT_USERNAME.to_string(), + home_dir: PathBuf::from("/root"), + } + } +} + +pub fn repair_account_database_permissions(fs_root: &Path) -> io::Result<()> { + #[cfg(not(unix))] + { + let _ = fs_root; + return Ok(()); + } + + #[cfg(unix)] + for relative_path in ["etc/passwd", "etc/group"] { + let path = fs_root.join(relative_path); + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + + let mut permissions = metadata.permissions(); + if permissions.mode() & 0o777 != 0o644 { + permissions.set_mode(0o644); + fs::set_permissions(path, permissions)?; + } + } + + #[cfg(unix)] + return Ok(()); +} + +fn find_in_passwd(fs_root: &Path, requested_username: &str) -> Option { + let passwd = fs::read_to_string(fs_root.join("etc/passwd")).ok()?; + + passwd.lines().find_map(|line| { + let mut fields = line.splitn(7, ':'); + let username = fields.next()?; + if username != requested_username { + return None; + } + + let home_dir = PathBuf::from(fields.nth(4)?); + if !is_safe_absolute_path(&home_dir) { + return None; + } + + Some(GuestUser { + username: username.to_string(), + home_dir, + }) + }) +} + +fn is_safe_absolute_path(path: &Path) -> bool { + let mut components = path.components(); + if components.next() != Some(Component::RootDir) { + return false; + } + + let mut has_directory = false; + for component in components { + if !matches!(component, Component::Normal(_)) { + return false; + } + has_directory = true; + } + has_directory +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write_passwd(fs_root: &Path) { + fs::create_dir_all(fs_root.join("etc")).unwrap(); + fs::write( + fs_root.join("etc/passwd"), + "root:x:0:0:root:/root:/bin/bash\nalice:x:1001:1001::/srv/alice:/bin/bash\n", + ) + .unwrap(); + } + + #[test] + fn resolves_an_existing_user_and_home_directory() { + let dir = tempdir().unwrap(); + write_passwd(dir.path()); + + let user = GuestUser::resolve(dir.path(), "alice"); + + assert_eq!(user.username(), "alice"); + assert_eq!(user.guest_home_dir(), Path::new("/srv/alice")); + assert_eq!(user.host_home_dir(dir.path()), dir.path().join("srv/alice")); + } + + #[test] + fn falls_back_to_root_when_the_requested_user_is_missing() { + let dir = tempdir().unwrap(); + write_passwd(dir.path()); + + let user = GuestUser::resolve(dir.path(), "missing"); + + assert_eq!(user.username(), ROOT_USERNAME); + assert_eq!(user.guest_home_dir(), Path::new("/root")); + } + + #[test] + fn rejects_home_directories_that_escape_the_guest_root() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("etc")).unwrap(); + fs::write( + dir.path().join("etc/passwd"), + "root:x:0:0:root:/root:/bin/bash\nalice:x:1001:1001::/home/../../tmp:/bin/bash\n", + ) + .unwrap(); + + let user = GuestUser::resolve(dir.path(), "alice"); + + assert_eq!(user.username(), ROOT_USERNAME); + } + + #[cfg(unix)] + #[test] + fn repairs_public_account_database_permissions() { + let dir = tempdir().unwrap(); + write_passwd(dir.path()); + fs::write(dir.path().join("etc/group"), "root:x:0:\n").unwrap(); + fs::set_permissions( + dir.path().join("etc/passwd"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + fs::set_permissions( + dir.path().join("etc/group"), + fs::Permissions::from_mode(0o660), + ) + .unwrap(); + + repair_account_database_permissions(dir.path()).unwrap(); + + for relative_path in ["etc/passwd", "etc/group"] { + let mode = fs::metadata(dir.path().join(relative_path)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o644); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 2f790e2..63cf796 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod core { pub mod config; + pub mod guest_user; } #[cfg(target_os = "android")]