1use super::exec::{exec_command, exec_command_exit_code, exec_command_with_stdin};
11use super::volume::MOUNT_USERS;
12use super::{DockerClient, DockerError};
13use serde::{Deserialize, Serialize};
14
15const USERS_STORE_DIR: &str = MOUNT_USERS;
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct UserInfo {
24 pub username: String,
26 pub uid: u32,
28 pub home: String,
30 pub shell: String,
32 pub locked: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37struct PersistedUserRecord {
38 username: String,
39 password_hash: String,
40 locked: bool,
41}
42
43pub async fn create_user(
58 client: &DockerClient,
59 container: &str,
60 username: &str,
61) -> Result<(), DockerError> {
62 let cmd = vec!["useradd", "-m", "-s", "/bin/bash", username];
63
64 let exit_code = exec_command_exit_code(client, container, cmd).await?;
65
66 if exit_code != 0 {
67 if user_exists(client, container, username).await? {
69 return Err(DockerError::Container(format!(
70 "User '{username}' already exists"
71 )));
72 }
73 return Err(DockerError::Container(format!(
74 "Failed to create user '{username}': useradd returned exit code {exit_code}"
75 )));
76 }
77
78 Ok(())
79}
80
81pub async fn set_user_password(
101 client: &DockerClient,
102 container: &str,
103 username: &str,
104 password: &str,
105) -> Result<(), DockerError> {
106 let cmd = vec!["chpasswd"];
107 let stdin_data = format!("{username}:{password}\n");
108
109 exec_command_with_stdin(client, container, cmd, &stdin_data).await?;
110
111 Ok(())
112}
113
114async fn set_user_password_hash(
118 client: &DockerClient,
119 container: &str,
120 username: &str,
121 password_hash: &str,
122) -> Result<(), DockerError> {
123 if password_hash.is_empty() {
124 return Ok(());
125 }
126
127 let cmd = vec!["usermod", "-p", password_hash, username];
128 let exit_code = exec_command_exit_code(client, container, cmd).await?;
129
130 if exit_code != 0 {
131 return Err(DockerError::Container(format!(
132 "Failed to set password hash for '{username}': usermod returned exit code {exit_code}"
133 )));
134 }
135
136 Ok(())
137}
138
139pub async fn user_exists(
149 client: &DockerClient,
150 container: &str,
151 username: &str,
152) -> Result<bool, DockerError> {
153 let cmd = vec!["id", "-u", username];
154 let exit_code = exec_command_exit_code(client, container, cmd).await?;
155
156 Ok(exit_code == 0)
157}
158
159pub async fn lock_user(
169 client: &DockerClient,
170 container: &str,
171 username: &str,
172) -> Result<(), DockerError> {
173 let cmd = vec!["passwd", "-l", username];
174 let exit_code = exec_command_exit_code(client, container, cmd).await?;
175
176 if exit_code != 0 {
177 return Err(DockerError::Container(format!(
178 "Failed to lock user '{username}': passwd returned exit code {exit_code}"
179 )));
180 }
181
182 Ok(())
183}
184
185pub async fn unlock_user(
194 client: &DockerClient,
195 container: &str,
196 username: &str,
197) -> Result<(), DockerError> {
198 let cmd = vec!["passwd", "-u", username];
199 let exit_code = exec_command_exit_code(client, container, cmd).await?;
200
201 if exit_code != 0 {
202 return Err(DockerError::Container(format!(
203 "Failed to unlock user '{username}': passwd returned exit code {exit_code}"
204 )));
205 }
206
207 Ok(())
208}
209
210pub async fn delete_user(
219 client: &DockerClient,
220 container: &str,
221 username: &str,
222) -> Result<(), DockerError> {
223 let cmd = vec!["userdel", "-r", username];
224 let exit_code = exec_command_exit_code(client, container, cmd).await?;
225
226 if exit_code != 0 {
227 if !user_exists(client, container, username).await? {
229 return Err(DockerError::Container(format!(
230 "User '{username}' does not exist"
231 )));
232 }
233 return Err(DockerError::Container(format!(
234 "Failed to delete user '{username}': userdel returned exit code {exit_code}"
235 )));
236 }
237
238 Ok(())
239}
240
241pub async fn list_users(
250 client: &DockerClient,
251 container: &str,
252) -> Result<Vec<UserInfo>, DockerError> {
253 let cmd = vec!["sh", "-c", "getent passwd | grep '/home/'"];
255 let output = exec_command(client, container, cmd).await?;
256
257 let mut users = Vec::new();
258
259 for line in output.lines() {
260 if let Some(info) = parse_passwd_line(line) {
261 let locked = is_user_locked(client, container, &info.username).await?;
263
264 users.push(UserInfo {
265 username: info.username,
266 uid: info.uid,
267 home: info.home,
268 shell: info.shell,
269 locked,
270 });
271 }
272 }
273
274 Ok(users)
275}
276
277pub async fn persist_user(
281 client: &DockerClient,
282 container: &str,
283 username: &str,
284) -> Result<(), DockerError> {
285 ensure_users_store_dir(client, container).await?;
286
287 let shadow_hash = get_user_shadow_hash(client, container, username).await?;
288 let locked = is_user_locked(client, container, username).await?;
289
290 let record = PersistedUserRecord {
291 username: username.to_string(),
292 password_hash: shadow_hash,
293 locked,
294 };
295
296 write_user_record(client, container, &record).await?;
297 Ok(())
298}
299
300pub async fn remove_persisted_user(
302 client: &DockerClient,
303 container: &str,
304 username: &str,
305) -> Result<(), DockerError> {
306 let record_path = user_record_path(username);
307 let cmd_string = format!("rm -f {record_path}");
308 let cmd = vec!["sh", "-c", cmd_string.as_str()];
309 exec_command(client, container, cmd).await?;
310 Ok(())
311}
312
313pub async fn restore_persisted_users(
317 client: &DockerClient,
318 container: &str,
319) -> Result<Vec<String>, DockerError> {
320 let records = read_user_records(client, container).await?;
321 if records.is_empty() {
322 let users = list_users(client, container).await?;
323 let mut persisted = Vec::new();
324 for user in users {
325 persist_user(client, container, &user.username).await?;
326 persisted.push(user.username);
327 }
328 return Ok(persisted);
329 }
330
331 let mut restored = Vec::new();
332
333 for record in records {
334 if !user_exists(client, container, &record.username).await? {
335 create_user(client, container, &record.username).await?;
336 }
337
338 set_user_password_hash(client, container, &record.username, &record.password_hash).await?;
339
340 if record.locked {
341 lock_user(client, container, &record.username).await?;
342 } else {
343 unlock_user(client, container, &record.username).await?;
344 }
345
346 restored.push(record.username);
347 }
348
349 Ok(restored)
350}
351
352async fn is_user_locked(
357 client: &DockerClient,
358 container: &str,
359 username: &str,
360) -> Result<bool, DockerError> {
361 let cmd = vec!["passwd", "-S", username];
362 let output = exec_command(client, container, cmd).await?;
363
364 let parts: Vec<&str> = output.split_whitespace().collect();
367 if parts.len() >= 2 {
368 return Ok(parts[1] == "L");
369 }
370
371 Ok(false)
372}
373
374async fn ensure_users_store_dir(client: &DockerClient, container: &str) -> Result<(), DockerError> {
375 let cmd_string = format!("install -d -m 700 {USERS_STORE_DIR}");
376 let cmd = vec!["sh", "-c", cmd_string.as_str()];
377 exec_command(client, container, cmd).await?;
378 Ok(())
379}
380
381async fn get_user_shadow_hash(
382 client: &DockerClient,
383 container: &str,
384 username: &str,
385) -> Result<String, DockerError> {
386 let output = exec_command(client, container, vec!["getent", "shadow", username]).await?;
387 let line = output.lines().next().unwrap_or("").trim();
388 if line.is_empty() {
389 return Err(DockerError::Container(format!(
390 "Failed to read shadow entry for '{username}'"
391 )));
392 }
393
394 let fields: Vec<&str> = line.split(':').collect();
395 if fields.len() < 2 {
396 return Err(DockerError::Container(format!(
397 "Invalid shadow entry for '{username}'"
398 )));
399 }
400
401 Ok(fields[1].to_string())
402}
403
404async fn read_user_records(
405 client: &DockerClient,
406 container: &str,
407) -> Result<Vec<PersistedUserRecord>, DockerError> {
408 let list_command =
409 format!("if [ -d {USERS_STORE_DIR} ]; then ls -1 {USERS_STORE_DIR}/*.json 2>/dev/null; fi");
410 let list_cmd = vec!["sh", "-c", list_command.as_str()];
411 let output = exec_command(client, container, list_cmd).await?;
412 let mut records = Vec::new();
413
414 for path in output
415 .lines()
416 .map(str::trim)
417 .filter(|line| !line.is_empty())
418 {
419 let contents = exec_command(client, container, vec!["cat", path]).await?;
420 let record: PersistedUserRecord = serde_json::from_str(&contents).map_err(|e| {
421 DockerError::Container(format!("Failed to parse user record {path}: {e}"))
422 })?;
423 records.push(record);
424 }
425
426 Ok(records)
427}
428
429fn user_record_path(username: &str) -> String {
430 format!("{USERS_STORE_DIR}/{username}.json")
431}
432
433async fn write_user_record(
434 client: &DockerClient,
435 container: &str,
436 record: &PersistedUserRecord,
437) -> Result<(), DockerError> {
438 let payload =
439 serde_json::to_string_pretty(record).map_err(|e| DockerError::Container(e.to_string()))?;
440 let record_path = user_record_path(&record.username);
441 let write_command =
442 format!("install -d -m 700 {USERS_STORE_DIR} && umask 077 && cat > {record_path}");
443 let cmd = vec!["sh", "-c", write_command.as_str()];
444 exec_command_with_stdin(client, container, cmd, &payload).await?;
445 Ok(())
446}
447
448struct ParsedUser {
450 username: String,
451 uid: u32,
452 home: String,
453 shell: String,
454}
455
456fn parse_passwd_line(line: &str) -> Option<ParsedUser> {
460 let fields: Vec<&str> = line.split(':').collect();
461 if fields.len() < 7 {
462 return None;
463 }
464
465 let uid = fields[2].parse::<u32>().ok()?;
466
467 Some(ParsedUser {
468 username: fields[0].to_string(),
469 uid,
470 home: fields[5].to_string(),
471 shell: fields[6].to_string(),
472 })
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn test_parse_passwd_line_valid() {
481 let line = "admin:x:1001:1001:Admin User:/home/admin:/bin/bash";
482 let parsed = parse_passwd_line(line).unwrap();
483 assert_eq!(parsed.username, "admin");
484 assert_eq!(parsed.uid, 1001);
485 assert_eq!(parsed.home, "/home/admin");
486 assert_eq!(parsed.shell, "/bin/bash");
487 }
488
489 #[test]
490 fn test_parse_passwd_line_minimal() {
491 let line = "user:x:1000:1000::/home/user:/bin/sh";
492 let parsed = parse_passwd_line(line).unwrap();
493 assert_eq!(parsed.username, "user");
494 assert_eq!(parsed.uid, 1000);
495 assert_eq!(parsed.home, "/home/user");
496 assert_eq!(parsed.shell, "/bin/sh");
497 }
498
499 #[test]
500 fn test_parse_passwd_line_invalid() {
501 assert!(parse_passwd_line("invalid").is_none());
502 assert!(parse_passwd_line("too:few:fields").is_none());
503 assert!(parse_passwd_line("user:x:not_a_number:1000::/home/user:/bin/bash").is_none());
504 }
505
506 #[test]
507 fn test_user_info_struct() {
508 let info = UserInfo {
509 username: "admin".to_string(),
510 uid: 1001,
511 home: "/home/admin".to_string(),
512 shell: "/bin/bash".to_string(),
513 locked: false,
514 };
515 assert_eq!(info.username, "admin");
516 assert!(!info.locked);
517 }
518
519 #[test]
520 fn test_user_info_equality() {
521 let info1 = UserInfo {
522 username: "admin".to_string(),
523 uid: 1001,
524 home: "/home/admin".to_string(),
525 shell: "/bin/bash".to_string(),
526 locked: false,
527 };
528 let info2 = info1.clone();
529 assert_eq!(info1, info2);
530 }
531
532 #[test]
533 fn test_user_info_debug() {
534 let info = UserInfo {
535 username: "test".to_string(),
536 uid: 1000,
537 home: "/home/test".to_string(),
538 shell: "/bin/bash".to_string(),
539 locked: true,
540 };
541 let debug = format!("{info:?}");
542 assert!(debug.contains("test"));
543 assert!(debug.contains("1000"));
544 assert!(debug.contains("locked: true"));
545 }
546}