diff --git a/rust/userborn/Cargo.lock b/rust/userborn/Cargo.lock index 03c205d..5b65bbc 100644 --- a/rust/userborn/Cargo.lock +++ b/rust/userborn/Cargo.lock @@ -100,6 +100,16 @@ dependencies = [ "log", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -110,6 +120,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "glob" version = "0.3.3" @@ -153,6 +181,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.28" @@ -215,6 +249,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "regex" version = "1.11.2" @@ -250,6 +290,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "ryu" version = "1.0.20" @@ -316,6 +369,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "unicode-ident" version = "1.0.19" @@ -333,22 +399,47 @@ dependencies = [ "log", "serde", "serde_json", + "tempfile", "xcrypt", ] +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "windows-link" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", @@ -407,6 +498,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "xcrypt" version = "0.3.1" diff --git a/rust/userborn/Cargo.toml b/rust/userborn/Cargo.toml index 541279e..3e1e55b 100644 --- a/rust/userborn/Cargo.toml +++ b/rust/userborn/Cargo.toml @@ -14,6 +14,7 @@ xcrypt = "0.3.1" [dev-dependencies] indoc = "2.0.6" expect-test = "1.5.1" +tempfile = "3.8.1" [profile.release] opt-level = "s" diff --git a/rust/userborn/src/config.rs b/rust/userborn/src/config.rs index 12f9c06..3205d42 100644 --- a/rust/userborn/src/config.rs +++ b/rust/userborn/src/config.rs @@ -4,7 +4,7 @@ use std::{fs::File, io::Read, path::Path}; use anyhow::{Context, Result}; use serde::Deserialize; -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone, PartialEq, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct User { /// Whether the user is a "normal" or a "system" user @@ -28,7 +28,7 @@ pub struct User { pub password: Password, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone, PartialEq, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct Password { pub password: Option, @@ -38,7 +38,7 @@ pub struct Password { pub initial_hashed_password: Option, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone, PartialEq, serde::Serialize)] pub struct Group { /// Whether the group is a "normal" or a "system" group #[serde(default)] @@ -52,7 +52,7 @@ pub struct Group { pub members: BTreeSet, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone, PartialEq, serde::Serialize)] pub struct Config { #[serde(default)] pub users: Vec, diff --git a/rust/userborn/src/group.rs b/rust/userborn/src/group.rs index ca1dc2a..67c58f7 100644 --- a/rust/userborn/src/group.rs +++ b/rust/userborn/src/group.rs @@ -77,6 +77,10 @@ impl Entry { pub fn name(&self) -> &str { &self.name } + + pub fn user_list(&self) -> &BTreeSet { + &self.user_list + } } /// Split a string containing group members separated by `,` into a list. diff --git a/rust/userborn/src/main.rs b/rust/userborn/src/main.rs index c03a5d3..4551b73 100644 --- a/rust/userborn/src/main.rs +++ b/rust/userborn/src/main.rs @@ -5,8 +5,13 @@ mod id; mod passwd; mod password; mod shadow; +mod state; -use std::{collections::BTreeSet, io::Write, process::ExitCode}; +use std::{ + collections::{BTreeSet, HashSet}, + io::Write, + process::ExitCode, +}; use anyhow::{anyhow, Context, Result}; use log::{Level, LevelFilter}; @@ -16,6 +21,7 @@ use group::Group; use passwd::Passwd; use password::HashedPassword; use shadow::Shadow; +use state::{OwnershipDiff, StateManager}; /// Fallback path to the nologin binary. /// @@ -73,7 +79,47 @@ fn run() -> Result<()> { let mut passwd_db = Passwd::from_file(&passwd_path).unwrap_or_default(); let mut shadow_db = Shadow::from_file(&shadow_path).unwrap_or_default(); - update_users_and_groups(&config, &mut group_db, &mut passwd_db, &mut shadow_db); + // Check if stateful mode is enabled via environment variable + let stateful_mode = std::env::var("USERBORN_STATEFUL") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false); + + if stateful_mode { + log::info!("Running in stateful mode"); + + // Initialize state manager + let state_manager = StateManager::new(&directory); + + // Load previously managed entities + let previous_managed = state_manager + .load_managed_entities() + .context("Failed to load managed entities state")?; + + // Compute ownership changes + let diff = OwnershipDiff::compute(&previous_managed, &config); + + // Log what changes we detected + diff.log_changes(&previous_managed); + + // Apply updates with stateful behavior + update_users_and_groups_diff( + &config, + &mut group_db, + &mut passwd_db, + &mut shadow_db, + Some(&diff), + ); + + // Save the current managed entities + state_manager + .save_managed_entities(&config) + .context("Failed to save managed entities state")?; + } else { + log::info!("Running in stateless mode"); + + // Use stateless behavior + update_users_and_groups_diff(&config, &mut group_db, &mut passwd_db, &mut shadow_db, None); + } warn_about_weak_password_hashes(&shadow_db); @@ -90,33 +136,87 @@ fn run() -> Result<()> { /// Create and update users and groups in the provided databases. /// /// Doesn't actually write anything to disk, only mutates the databases in memory. -fn update_users_and_groups( +fn update_users_and_groups_diff( config: &Config, group_db: &mut Group, passwd_db: &mut Passwd, shadow_db: &mut Shadow, + diff: Option<&OwnershipDiff>, ) { - let mut groups_in_config: BTreeSet<&str> = BTreeSet::new(); + match diff { + Some(ownership_diff) => { + // Remove groups that are no longer managed + for group_name in &ownership_diff.groups_to_remove { + if let Some(existing_entry) = group_db.get_mut(group_name) { + existing_entry.update(BTreeSet::new()); + log::info!("Emptied previously managed group {}", group_name); + } + } - for group_config in &config.groups { - groups_in_config.insert(&group_config.name); + // Remove users that are no longer managed + let users_to_remove_refs: BTreeSet<&str> = ownership_diff + .users_to_remove + .iter() + .map(|s| s.as_str()) + .collect(); + lock_users(shadow_db, &users_to_remove_refs, "previously managed"); + } + None => { + // Stateless mode: clean up all unmanaged entities + let groups_in_config: BTreeSet<&str> = + config.groups.iter().map(|g| g.name.as_str()).collect(); + let users_in_config: BTreeSet<&str> = + config.users.iter().map(|u| u.name.as_str()).collect(); + + // Empty groups not in config + for entry in group_db.entries_mut() { + if !groups_in_config.contains(entry.name()) { + entry.update(BTreeSet::new()); + } + } + + // Lock users not in config + let users_to_lock: BTreeSet = shadow_db + .entries() + .into_iter() + .map(|entry| entry.name().to_owned()) + .filter(|name| !users_in_config.contains(name.as_str())) + .collect(); + + let users_to_lock_refs: BTreeSet<&str> = + users_to_lock.iter().map(|s| s.as_str()).collect(); + lock_users(shadow_db, &users_to_lock_refs, ""); + } + } + // Process all configured groups and users + process_groups_from_config(config, group_db); + process_users_from_config(config, group_db, passwd_db, shadow_db); + + // Update implicit primary groups + let users_to_remove = diff.map(|d| &d.users_to_remove); + update_implicit_primary_groups(config, group_db, users_to_remove); +} + +/// Process all groups from the config, updating existing ones or creating new ones. +fn process_groups_from_config(config: &Config, group_db: &mut Group) { + for group_config in &config.groups { if let Some(existing_entry) = group_db.get_mut(&group_config.name) { existing_entry.update(group_config.members.clone()); } else if let Err(e) = create_group(group_config, group_db) { log::error!("Failed to create group {}: {e:#}", group_config.name); } } +} - let mut users_in_config: BTreeSet<&str> = BTreeSet::new(); - let mut implicit_primary_groups: BTreeSet<&str> = BTreeSet::new(); - +/// Process all users from the config, updating existing ones or creating new ones. +fn process_users_from_config( + config: &Config, + group_db: &mut Group, + passwd_db: &mut Passwd, + shadow_db: &mut Shadow, +) { for user_config in &config.users { - users_in_config.insert(&user_config.name); - if user_config.group.is_none() { - implicit_primary_groups.insert(&user_config.name); - } - if let Some(existing_entry) = passwd_db.get_mut(&user_config.name) { if let Err(e) = update_user(existing_entry, user_config, group_db, shadow_db) { log::error!("Failed to update user {}: {e:#}", user_config.name); @@ -125,23 +225,59 @@ fn update_users_and_groups( log::error!("Failed to create user {}: {e:#}", user_config.name); } } +} + +/// Get the set of implicit primary groups (users without explicit group). +fn get_implicit_primary_groups(config: &Config) -> BTreeSet<&str> { + config + .users + .iter() + .filter(|user_config| user_config.group.is_none()) + .map(|user_config| user_config.name.as_str()) + .collect() +} + +/// Update implicit primary groups to contain only their associated user. +/// If users_to_remove is provided, also empty groups for those removed users. +fn update_implicit_primary_groups( + config: &Config, + group_db: &mut Group, + users_to_remove: Option<&HashSet>, +) { + let implicit_primary_groups = get_implicit_primary_groups(config); - // Find groups in the DB that are not in the config and empty them. for entry in group_db.entries_mut() { - if !groups_in_config.contains(entry.name()) { - if implicit_primary_groups.contains(entry.name()) { - entry.update(BTreeSet::from([entry.name().to_owned()])); - } else { - entry.update(BTreeSet::new()); + if implicit_primary_groups.contains(entry.name()) { + let should_be_members = BTreeSet::from([entry.name().to_owned()]); + if entry.user_list() != &should_be_members { + entry.update(should_be_members); + log::debug!("Updated implicit primary group {}", entry.name()); + } + } else if let Some(users_to_remove) = users_to_remove { + // In stateful mode, also empty groups for removed users + if users_to_remove.contains(entry.name()) { + if !entry.user_list().is_empty() { + entry.update(BTreeSet::new()); + log::debug!( + "Emptied implicit primary group for removed user {}", + entry.name() + ); + } } } } +} - // Find users in the shadow DB that are not in the config and disable them. - for entry in shadow_db.entries_mut() { - if !users_in_config.contains(entry.name()) { - log::info!("Locking account for user {}...", entry.name()); - entry.lock_account(); +/// Lock user accounts that are in the provided list. +fn lock_users(shadow_db: &mut Shadow, users_to_lock: &BTreeSet<&str>, context: &str) { + for username in users_to_lock { + if let Some(existing_entry) = shadow_db.get_mut(username) { + if context.is_empty() { + log::info!("Locking account for user {}...", username); + } else { + log::info!("Locking account for {} user {}...", context, username); + } + existing_entry.lock_account(); } } } @@ -418,8 +554,11 @@ mod tests { }))?) } - #[test] - fn update_users_and_groups_across_generations() -> Result<()> { + /// Generic test function that runs across generations with either stateful or stateless mode + fn test_across_generations_generic(use_stateful_mode: bool) -> Result<()> { + use crate::state::{OwnershipDiff, StateManager}; + use tempfile::TempDir; + // Explicitly set this because the expected values depend on this. std::env::set_var("USERBORN_NO_LOGIN_PATH", NO_LOGIN_FALLBACK); @@ -427,81 +566,203 @@ mod tests { let mut passwd_db = Passwd::default(); let mut shadow_db = Shadow::default(); - // GEN 0 + // Add unmanaged system user to test stateful mode + let system_user = passwd::Entry::new( + "system_user".to_string(), + 5000, + 5000, + "System".to_string(), + "/var/empty".to_string(), + "/bin/false".to_string(), + ); + passwd_db.insert(&system_user)?; + shadow_db.insert(&shadow::Entry::new( + "system_user".to_string(), + Some("$y$j9T$system.hash$test".to_string()), + ))?; + + // Set up state management + let temp_dir = if use_stateful_mode { + Some(TempDir::new().unwrap()) + } else { + None + }; - update_users_and_groups(&gen0()?, &mut group_db, &mut passwd_db, &mut shadow_db); + let state_manager = temp_dir + .as_ref() + .map(|td| StateManager::new(td.path().to_str().unwrap())); + + { + let config = gen0()?; + if let Some(ref sm) = state_manager { + let previous_managed = sm.load_managed_entities()?; + let diff = OwnershipDiff::compute(&previous_managed, &config); + update_users_and_groups_diff( + &config, + &mut group_db, + &mut passwd_db, + &mut shadow_db, + Some(&diff), + ); + sm.save_managed_entities(&config)?; + } else { + update_users_and_groups_diff( + &config, + &mut group_db, + &mut passwd_db, + &mut shadow_db, + None, + ); + } + } - let expected_group = expect![[r#" + let expected_group_gen0 = expect![[r#" root:x:0:root wheel:x:999:normalo normalo:x:1000:normalo "#]]; - expected_group.assert_eq(&group_db.to_buffer()); + expected_group_gen0.assert_eq(&group_db.to_buffer()); - let expected_passwd = expect![[r#" + let expected_passwd_gen0 = expect![[r#" root:x:0:0:::/run/current-system/sw/bin/nologin normalo:x:1000:1000::/home/normalo:/bin/bash + system_user:x:5000:5000:System:/var/empty:/bin/false "#]]; - expected_passwd.assert_eq(&passwd_db.to_buffer()); - - let expected_shadow = expect![[r#" - root:!*:1:::::: - normalo:$y$j9T$BOO.gstYxWh8Lw.njfytQ/$K4sN06nBh0qFGegFS0hn5YkEOzzrr7woGHlSiUuCqS4:1:::::: - "#]]; - expected_shadow.assert_eq(&shadow_db.to_buffer_sorted(&passwd_db)); + expected_passwd_gen0.assert_eq(&passwd_db.to_buffer()); - // GEN 1 - - update_users_and_groups(&gen1()?, &mut group_db, &mut passwd_db, &mut shadow_db); + let system_user_password = if use_stateful_mode { + "$y$j9T$system.hash$test" + } else { + "!*" + }; + let expected_shadow_gen0 = format!( + "root:!*:1::::::\nnormalo:$y$j9T$BOO.gstYxWh8Lw.njfytQ/$K4sN06nBh0qFGegFS0hn5YkEOzzrr7woGHlSiUuCqS4:1::::::\nsystem_user:{}:1::::::", + system_user_password + ); + assert_eq!( + shadow_db.to_buffer_sorted(&passwd_db).trim(), + expected_shadow_gen0 + ); + + { + let config = gen1()?; + if let Some(ref sm) = state_manager { + let previous_managed = sm.load_managed_entities()?; + let diff = OwnershipDiff::compute(&previous_managed, &config); + update_users_and_groups_diff( + &config, + &mut group_db, + &mut passwd_db, + &mut shadow_db, + Some(&diff), + ); + sm.save_managed_entities(&config)?; + } else { + update_users_and_groups_diff( + &config, + &mut group_db, + &mut passwd_db, + &mut shadow_db, + None, + ); + } + } - let expected_group = expect![[r#" + let expected_group_gen1 = expect![[r#" root:x:0:root initial:x:998:initial wheel:x:999:initial,normalo normalo:x:1000:normalo "#]]; - expected_group.assert_eq(&group_db.to_buffer()); + expected_group_gen1.assert_eq(&group_db.to_buffer()); - let expected_passwd = expect![[r#" + let expected_passwd_gen1 = expect![[r#" root:x:0:0:::/run/current-system/sw/bin/nologin initial:x:999:999:::/run/current-system/sw/bin/nologin normalo:x:1000:1000::/home/normalo:/bin/zsh + system_user:x:5000:5000:System:/var/empty:/bin/false "#]]; - expected_passwd.assert_eq(&passwd_db.to_buffer()); - - let expected_shadow = expect![[r#" - root:!*:1:::::: - initial:$y$j9T$2e5ARUyMfmJ0nW9ZMPFg50$EGgRGQBqq0r/fxRlIRXL86K61o/ESEsIdVZYkyQvyN2:1:::::: - normalo:$y$j9T$BOO.gstYxWh8Lw.njfytQ/$K4sN06nBh0qFGegFS0hn5YkEOzzrr7woGHlSiUuCqS4:1:::::: - "#]]; - expected_shadow.assert_eq(&shadow_db.to_buffer_sorted(&passwd_db)); - - // GEN 2 + expected_passwd_gen1.assert_eq(&passwd_db.to_buffer()); - update_users_and_groups(&gen2()?, &mut group_db, &mut passwd_db, &mut shadow_db); + // Shadow differs only by system_user lock status (checked above) + let system_user_password = if use_stateful_mode { + "$y$j9T$system.hash$test" + } else { + "!*" + }; + let expected_shadow_gen1 = format!( + "root:!*:1::::::\ninitial:$y$j9T$2e5ARUyMfmJ0nW9ZMPFg50$EGgRGQBqq0r/fxRlIRXL86K61o/ESEsIdVZYkyQvyN2:1::::::\nnormalo:$y$j9T$BOO.gstYxWh8Lw.njfytQ/$K4sN06nBh0qFGegFS0hn5YkEOzzrr7woGHlSiUuCqS4:1::::::\nsystem_user:{}:1::::::", + system_user_password + ); + assert_eq!( + shadow_db.to_buffer_sorted(&passwd_db).trim(), + expected_shadow_gen1 + ); + + { + let config = gen2()?; + if let Some(ref sm) = state_manager { + let previous_managed = sm.load_managed_entities()?; + let diff = OwnershipDiff::compute(&previous_managed, &config); + update_users_and_groups_diff( + &config, + &mut group_db, + &mut passwd_db, + &mut shadow_db, + Some(&diff), + ); + sm.save_managed_entities(&config)?; + } else { + update_users_and_groups_diff( + &config, + &mut group_db, + &mut passwd_db, + &mut shadow_db, + None, + ); + } + } - let expected_group = expect![[r#" - root:x:0:root - initial:x:998: - wheel:x:999: - normalo:x:1000:normalo - "#]]; - expected_group.assert_eq(&group_db.to_buffer()); + let expected_group_gen2 = expect![[r#" + root:x:0:root + initial:x:998: + wheel:x:999: + normalo:x:1000:normalo + "#]]; + expected_group_gen2.assert_eq(&group_db.to_buffer()); - let expected_passwd = expect![[r#" + let expected_passwd_gen2 = expect![[r#" root:x:0:0::/root:/run/current-system/sw/bin/nologin initial:x:999:999:::/run/current-system/sw/bin/nologin normalo:x:1000:1000:I'm normal I swear:/home/normalo:/bin/zsh + system_user:x:5000:5000:System:/var/empty:/bin/false "#]]; - expected_passwd.assert_eq(&passwd_db.to_buffer()); + expected_passwd_gen2.assert_eq(&passwd_db.to_buffer()); - let expected_shadow = expect![[r#" - root:!*:1:::::: - initial:!*:1:::::: - normalo:$y$j9T$CZSAJTLCfrBvcCgvOTY4W1$G7uzyX3O6K.DR8KJLL/oL.8EREPSRTIjBn76SpvcH4A:1:::::: - "#]]; - expected_shadow.assert_eq(&shadow_db.to_buffer_sorted(&passwd_db)); + let system_user_password = if use_stateful_mode { + "$y$j9T$system.hash$test" + } else { + "!*" + }; + let expected_shadow_gen2 = format!( + "root:!*:1::::::\ninitial:!*:1::::::\nnormalo:$y$j9T$CZSAJTLCfrBvcCgvOTY4W1$G7uzyX3O6K.DR8KJLL/oL.8EREPSRTIjBn76SpvcH4A:1::::::\nsystem_user:{}:1::::::", + system_user_password + ); + assert_eq!( + shadow_db.to_buffer_sorted(&passwd_db).trim(), + expected_shadow_gen2 + ); Ok(()) } + + #[test] + fn update_users_and_groups_across_generations() -> Result<()> { + test_across_generations_generic(false) + } + + #[test] + fn update_users_and_groups_across_generations_stateful() -> Result<()> { + test_across_generations_generic(true) + } } diff --git a/rust/userborn/src/shadow.rs b/rust/userborn/src/shadow.rs index 85ca490..2032bfb 100644 --- a/rust/userborn/src/shadow.rs +++ b/rust/userborn/src/shadow.rs @@ -176,10 +176,6 @@ impl Shadow { pub fn entries(&self) -> impl IntoIterator { self.0.values() } - - pub fn entries_mut(&mut self) -> impl IntoIterator { - self.0.values_mut() - } } /// Determine whether a hashing scheme used in a password is secure. diff --git a/rust/userborn/src/state.rs b/rust/userborn/src/state.rs new file mode 100644 index 0000000..139cd84 --- /dev/null +++ b/rust/userborn/src/state.rs @@ -0,0 +1,215 @@ +use std::collections::HashSet; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; + +const STATE_FILE_NAME: &str = "userborn.state"; + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct ManagedEntities { + pub users: HashSet, + pub groups: HashSet, +} + +pub struct StateManager { + state_file_path: PathBuf, +} + +impl StateManager { + pub fn new(directory: &str) -> Self { + Self { + state_file_path: Path::new(directory).join(STATE_FILE_NAME), + } + } + + pub fn load_managed_entities(&self) -> Result { + if !self.state_file_path.exists() { + return Ok(ManagedEntities::default()); + } + + let mut file = File::open(&self.state_file_path) + .with_context(|| format!("Failed to open state file: {:?}", self.state_file_path))?; + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .with_context(|| "Failed to read state file")?; + + let managed: ManagedEntities = serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse state file: {:?}. The file may be corrupted. Delete it to reset state or fix the JSON syntax.", self.state_file_path))?; + + Ok(managed) + } + + pub fn save_managed_entities(&self, config: &Config) -> Result<()> { + let managed = ManagedEntities { + users: config.users.iter().map(|u| u.name.clone()).collect(), + groups: config.groups.iter().map(|g| g.name.clone()).collect(), + }; + + let json = serde_json::to_string_pretty(&managed) + .with_context(|| "Failed to serialize managed entities")?; + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&self.state_file_path) + .with_context(|| format!("Failed to create/open state file: {:?}", self.state_file_path))?; + + file.write_all(json.as_bytes()) + .with_context(|| "Failed to write state file")?; + + log::debug!("Saved managed entities to state file: {:?}", self.state_file_path); + Ok(()) + } +} + +pub struct OwnershipDiff { + pub users_to_manage: HashSet, + pub groups_to_manage: HashSet, + pub users_to_remove: HashSet, + pub groups_to_remove: HashSet, +} + +impl OwnershipDiff { + pub fn compute(previous: &ManagedEntities, config: &Config) -> Self { + let current_users: HashSet = config.users.iter().map(|u| u.name.clone()).collect(); + let current_groups: HashSet = config.groups.iter().map(|g| g.name.clone()).collect(); + + let users_to_remove = previous.users.difference(¤t_users).cloned().collect(); + let groups_to_remove = previous.groups.difference(¤t_groups).cloned().collect(); + + Self { + users_to_manage: current_users, + groups_to_manage: current_groups, + users_to_remove, + groups_to_remove, + } + } + + pub fn has_changes(&self, previous: &ManagedEntities) -> bool { + self.users_to_manage != previous.users + || self.groups_to_manage != previous.groups + } + + pub fn log_changes(&self, previous: &ManagedEntities) { + if !self.has_changes(previous) { + log::info!("No ownership changes detected."); + return; + } + + log::info!("Ownership changes detected:"); + + for user in &self.users_to_manage { + if !previous.users.contains(user) { + log::info!(" + Taking ownership of user: {}", user); + } else { + log::info!(" ~ Managing user: {}", user); + } + } + + for user in &self.users_to_remove { + log::info!(" - Removing managed user: {}", user); + } + + for group in &self.groups_to_manage { + if !previous.groups.contains(group) { + log::info!(" + Taking ownership of group: {}", group); + } else { + log::info!(" ~ Managing group: {}", group); + } + } + + for group in &self.groups_to_remove { + log::info!(" - Removing managed group: {}", group); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Group as ConfigGroup, User as ConfigUser, Password}; + + fn make_user(name: &str) -> ConfigUser { + ConfigUser { + is_normal: true, + name: name.to_string(), + uid: None, + group: None, + description: None, + home: None, + shell: None, + password: Password { + password: None, + hashed_password: None, + hashed_password_file: None, + initial_password: None, + initial_hashed_password: None, + }, + } + } + + fn make_group(name: &str) -> ConfigGroup { + ConfigGroup { + is_normal: true, + name: name.to_string(), + gid: None, + members: Default::default(), + } + } + + #[test] + fn test_no_changes() { + let previous = ManagedEntities { + users: ["user1"].iter().map(|s| s.to_string()).collect(), + groups: ["group1"].iter().map(|s| s.to_string()).collect(), + }; + + let config = Config { + users: vec![make_user("user1")], + groups: vec![make_group("group1")], + }; + + let diff = OwnershipDiff::compute(&previous, &config); + assert!(!diff.has_changes(&previous)); + } + + #[test] + fn test_user_added() { + let previous = ManagedEntities::default(); + + let config = Config { + users: vec![make_user("user1")], + groups: vec![], + }; + + let diff = OwnershipDiff::compute(&previous, &config); + assert!(diff.has_changes(&previous)); + assert!(diff.users_to_manage.contains("user1")); + assert!(diff.users_to_remove.is_empty()); + } + + #[test] + fn test_user_removed() { + let previous = ManagedEntities { + users: ["user1"].iter().map(|s| s.to_string()).collect(), + groups: Default::default(), + }; + + let config = Config { + users: vec![], + groups: vec![], + }; + + let diff = OwnershipDiff::compute(&previous, &config); + assert!(diff.has_changes(&previous)); + assert!(diff.users_to_remove.contains("user1")); + assert!(!diff.users_to_manage.contains("user1")); + } +} \ No newline at end of file