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;
20const PROTECTED_SYSTEM_USER: &str = "opencoder";
21const HIDDEN_BUILTIN_USERS: [&str; 2] = [PROTECTED_SYSTEM_USER, "ubuntu"];
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct UserInfo {
26 pub username: String,
28 pub uid: u32,
30 pub home: String,
32 pub shell: String,
34 pub locked: bool,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39struct PersistedUserRecord {
40 username: String,
41 password_hash: String,
42 locked: bool,
43}
44
45pub async fn create_user(
60 client: &DockerClient,
61 container: &str,
62 username: &str,
63) -> Result<(), DockerError> {
64 let cmd = vec!["useradd", "-m", "-s", "/bin/bash", username];
65
66 let exit_code = exec_command_exit_code(client, container, cmd).await?;
67
68 if exit_code != 0 {
69 if user_exists(client, container, username).await? {
71 return Err(DockerError::Container(format!(
72 "User '{username}' already exists"
73 )));
74 }
75 return Err(DockerError::Container(format!(
76 "Failed to create user '{username}': useradd returned exit code {exit_code}"
77 )));
78 }
79
80 Ok(())
81}
82
83pub async fn set_user_password(
103 client: &DockerClient,
104 container: &str,
105 username: &str,
106 password: &str,
107) -> Result<(), DockerError> {
108 let cmd = vec!["chpasswd"];
109 let stdin_data = format!("{username}:{password}\n");
110
111 exec_command_with_stdin(client, container, cmd, &stdin_data).await?;
112
113 Ok(())
114}
115
116async fn set_user_password_hash(
120 client: &DockerClient,
121 container: &str,
122 username: &str,
123 password_hash: &str,
124) -> Result<(), DockerError> {
125 if password_hash.is_empty() {
126 return Ok(());
127 }
128
129 let cmd = vec!["usermod", "-p", password_hash, username];
130 let exit_code = exec_command_exit_code(client, container, cmd).await?;
131
132 if exit_code != 0 {
133 return Err(DockerError::Container(format!(
134 "Failed to set password hash for '{username}': usermod returned exit code {exit_code}"
135 )));
136 }
137
138 Ok(())
139}
140
141pub async fn user_exists(
151 client: &DockerClient,
152 container: &str,
153 username: &str,
154) -> Result<bool, DockerError> {
155 let cmd = vec!["id", "-u", username];
156 let exit_code = exec_command_exit_code(client, container, cmd).await?;
157
158 Ok(exit_code == 0)
159}
160
161pub async fn lock_user(
171 client: &DockerClient,
172 container: &str,
173 username: &str,
174) -> Result<(), DockerError> {
175 let cmd = vec!["passwd", "-l", username];
176 let exit_code = exec_command_exit_code(client, container, cmd).await?;
177
178 if exit_code != 0 {
179 return Err(DockerError::Container(format!(
180 "Failed to lock user '{username}': passwd returned exit code {exit_code}"
181 )));
182 }
183
184 Ok(())
185}
186
187pub async fn unlock_user(
196 client: &DockerClient,
197 container: &str,
198 username: &str,
199) -> Result<(), DockerError> {
200 let cmd = vec!["passwd", "-u", username];
201 let exit_code = exec_command_exit_code(client, container, cmd).await?;
202
203 if exit_code != 0 {
204 return Err(DockerError::Container(format!(
205 "Failed to unlock user '{username}': passwd returned exit code {exit_code}"
206 )));
207 }
208
209 Ok(())
210}
211
212pub async fn delete_user(
221 client: &DockerClient,
222 container: &str,
223 username: &str,
224) -> Result<(), DockerError> {
225 let cmd = vec!["userdel", "-r", username];
226 let exit_code = exec_command_exit_code(client, container, cmd).await?;
227
228 if exit_code != 0 {
229 if !user_exists(client, container, username).await? {
231 return Err(DockerError::Container(format!(
232 "User '{username}' does not exist"
233 )));
234 }
235 return Err(DockerError::Container(format!(
236 "Failed to delete user '{username}': userdel returned exit code {exit_code}"
237 )));
238 }
239
240 Ok(())
241}
242
243pub async fn list_users(
252 client: &DockerClient,
253 container: &str,
254) -> Result<Vec<UserInfo>, DockerError> {
255 let records = read_user_records(client, container).await?;
256 let mut users = Vec::new();
257
258 for username in managed_usernames_from_records(&records) {
259 if let Some(user) = read_user_info(client, container, &username).await? {
260 users.push(user);
261 }
262 }
263
264 users.sort_by(|a, b| a.username.cmp(&b.username));
265 Ok(users)
266}
267
268pub async fn persist_user(
272 client: &DockerClient,
273 container: &str,
274 username: &str,
275) -> Result<(), DockerError> {
276 if is_builtin_system_user(username) {
277 return Err(DockerError::Container(format!(
278 "User '{username}' is built-in and cannot be persisted"
279 )));
280 }
281
282 ensure_users_store_dir(client, container).await?;
283
284 let shadow_hash = get_user_shadow_hash(client, container, username).await?;
285 let locked = is_user_locked(client, container, username).await?;
286
287 let record = PersistedUserRecord {
288 username: username.to_string(),
289 password_hash: shadow_hash,
290 locked,
291 };
292
293 write_user_record(client, container, &record).await?;
294 Ok(())
295}
296
297pub async fn remove_persisted_user(
299 client: &DockerClient,
300 container: &str,
301 username: &str,
302) -> Result<(), DockerError> {
303 let record_path = user_record_path(username);
304 let cmd_string = format!("rm -f {record_path}");
305 let cmd = vec!["sh", "-c", cmd_string.as_str()];
306 exec_command(client, container, cmd).await?;
307 Ok(())
308}
309
310pub async fn restore_persisted_users(
314 client: &DockerClient,
315 container: &str,
316) -> Result<Vec<String>, DockerError> {
317 let records = read_user_records(client, container).await?;
318 if records.is_empty() {
319 let users = list_non_builtin_home_usernames(client, container).await?;
320 let mut persisted = Vec::new();
321 for username in users {
322 persist_user(client, container, &username).await?;
323 persisted.push(username);
324 }
325 return Ok(persisted);
326 }
327
328 let mut restored = Vec::new();
329
330 for record in records {
331 if !user_exists(client, container, &record.username).await? {
332 create_user(client, container, &record.username).await?;
333 }
334
335 set_user_password_hash(client, container, &record.username, &record.password_hash).await?;
336
337 if record.locked {
338 lock_user(client, container, &record.username).await?;
339 } else {
340 unlock_user(client, container, &record.username).await?;
341 }
342
343 restored.push(record.username);
344 }
345
346 Ok(restored)
347}
348
349async fn is_user_locked(
354 client: &DockerClient,
355 container: &str,
356 username: &str,
357) -> Result<bool, DockerError> {
358 let cmd = vec!["passwd", "-S", username];
359 let output = exec_command(client, container, cmd).await?;
360
361 let parts: Vec<&str> = output.split_whitespace().collect();
364 if parts.len() >= 2 {
365 return Ok(parts[1] == "L");
366 }
367
368 Ok(false)
369}
370
371async fn ensure_users_store_dir(client: &DockerClient, container: &str) -> Result<(), DockerError> {
372 let cmd_string = format!("install -d -m 700 {USERS_STORE_DIR}");
373 let cmd = vec!["sh", "-c", cmd_string.as_str()];
374 exec_command(client, container, cmd).await?;
375 Ok(())
376}
377
378async fn get_user_shadow_hash(
379 client: &DockerClient,
380 container: &str,
381 username: &str,
382) -> Result<String, DockerError> {
383 let output = exec_command(client, container, vec!["getent", "shadow", username]).await?;
384 let line = output.lines().next().unwrap_or("").trim();
385 if line.is_empty() {
386 return Err(DockerError::Container(format!(
387 "Failed to read shadow entry for '{username}'"
388 )));
389 }
390
391 let fields: Vec<&str> = line.split(':').collect();
392 if fields.len() < 2 {
393 return Err(DockerError::Container(format!(
394 "Invalid shadow entry for '{username}'"
395 )));
396 }
397
398 Ok(fields[1].to_string())
399}
400
401async fn read_user_info(
402 client: &DockerClient,
403 container: &str,
404 username: &str,
405) -> Result<Option<UserInfo>, DockerError> {
406 if !user_exists(client, container, username).await? {
408 return Ok(None);
409 }
410
411 let output = exec_command(client, container, vec!["getent", "passwd", username]).await?;
412 let line = output.lines().next().unwrap_or("").trim();
413 if line.is_empty() {
414 return Ok(None);
415 }
416
417 let info = parse_passwd_line(line).ok_or_else(|| {
418 DockerError::Container(format!("Failed to parse passwd entry for '{username}'"))
419 })?;
420 let locked = is_user_locked(client, container, &info.username).await?;
421
422 Ok(Some(UserInfo {
423 username: info.username,
424 uid: info.uid,
425 home: info.home,
426 shell: info.shell,
427 locked,
428 }))
429}
430
431async fn list_non_builtin_home_usernames(
432 client: &DockerClient,
433 container: &str,
434) -> Result<Vec<String>, DockerError> {
435 let cmd = vec!["sh", "-c", "getent passwd | grep '/home/'"];
436 let output = exec_command(client, container, cmd).await?;
437 let mut users = Vec::new();
438
439 for line in output.lines() {
440 let Some(info) = parse_passwd_line(line) else {
441 continue;
442 };
443 if is_builtin_system_user(&info.username) {
444 continue;
445 }
446 users.push(info.username);
447 }
448
449 Ok(users)
450}
451
452async fn read_user_records(
453 client: &DockerClient,
454 container: &str,
455) -> Result<Vec<PersistedUserRecord>, DockerError> {
456 let list_command =
457 format!("if [ -d {USERS_STORE_DIR} ]; then ls -1 {USERS_STORE_DIR}/*.json 2>/dev/null; fi");
458 let list_cmd = vec!["sh", "-c", list_command.as_str()];
459 let output = exec_command(client, container, list_cmd).await?;
460 let mut records = Vec::new();
461
462 for path in output
463 .lines()
464 .map(str::trim)
465 .filter(|line| !line.is_empty())
466 {
467 let contents = exec_command(client, container, vec!["cat", path]).await?;
468 let record: PersistedUserRecord = serde_json::from_str(&contents).map_err(|e| {
469 DockerError::Container(format!("Failed to parse user record {path}: {e}"))
470 })?;
471 if is_builtin_system_user(&record.username) {
472 continue;
473 }
474 records.push(record);
475 }
476
477 Ok(records)
478}
479
480fn user_record_path(username: &str) -> String {
481 format!("{USERS_STORE_DIR}/{username}.json")
482}
483
484fn is_builtin_system_user(username: &str) -> bool {
485 HIDDEN_BUILTIN_USERS.contains(&username)
486}
487
488fn managed_usernames_from_records(records: &[PersistedUserRecord]) -> Vec<String> {
489 records
490 .iter()
491 .map(|record| record.username.clone())
492 .filter(|username| !is_builtin_system_user(username))
493 .collect()
494}
495
496async fn write_user_record(
497 client: &DockerClient,
498 container: &str,
499 record: &PersistedUserRecord,
500) -> Result<(), DockerError> {
501 let payload =
502 serde_json::to_string_pretty(record).map_err(|e| DockerError::Container(e.to_string()))?;
503 let record_path = user_record_path(&record.username);
504 let write_command =
505 format!("install -d -m 700 {USERS_STORE_DIR} && umask 077 && cat > {record_path}");
506 let cmd = vec!["sh", "-c", write_command.as_str()];
507 exec_command_with_stdin(client, container, cmd, &payload).await?;
508 Ok(())
509}
510
511struct ParsedUser {
513 username: String,
514 uid: u32,
515 home: String,
516 shell: String,
517}
518
519fn parse_passwd_line(line: &str) -> Option<ParsedUser> {
523 let fields: Vec<&str> = line.split(':').collect();
524 if fields.len() < 7 {
525 return None;
526 }
527
528 let uid = fields[2].parse::<u32>().ok()?;
529
530 Some(ParsedUser {
531 username: fields[0].to_string(),
532 uid,
533 home: fields[5].to_string(),
534 shell: fields[6].to_string(),
535 })
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn test_parse_passwd_line_valid() {
544 let line = "admin:x:1001:1001:Admin User:/home/admin:/bin/bash";
545 let parsed = parse_passwd_line(line).unwrap();
546 assert_eq!(parsed.username, "admin");
547 assert_eq!(parsed.uid, 1001);
548 assert_eq!(parsed.home, "/home/admin");
549 assert_eq!(parsed.shell, "/bin/bash");
550 }
551
552 #[test]
553 fn test_parse_passwd_line_minimal() {
554 let line = "user:x:1000:1000::/home/user:/bin/sh";
555 let parsed = parse_passwd_line(line).unwrap();
556 assert_eq!(parsed.username, "user");
557 assert_eq!(parsed.uid, 1000);
558 assert_eq!(parsed.home, "/home/user");
559 assert_eq!(parsed.shell, "/bin/sh");
560 }
561
562 #[test]
563 fn test_parse_passwd_line_invalid() {
564 assert!(parse_passwd_line("invalid").is_none());
565 assert!(parse_passwd_line("too:few:fields").is_none());
566 assert!(parse_passwd_line("user:x:not_a_number:1000::/home/user:/bin/bash").is_none());
567 }
568
569 #[test]
570 fn test_protected_system_user_constant() {
571 assert_eq!(PROTECTED_SYSTEM_USER, "opencoder");
572 }
573
574 #[test]
575 fn test_is_builtin_system_user() {
576 assert!(is_builtin_system_user("opencoder"));
577 assert!(is_builtin_system_user("ubuntu"));
578 assert!(!is_builtin_system_user("admin"));
579 }
580
581 #[test]
582 fn test_managed_usernames_from_records_filters_builtin_users() {
583 let records = vec![
584 PersistedUserRecord {
585 username: "ubuntu".to_string(),
586 password_hash: "x".to_string(),
587 locked: true,
588 },
589 PersistedUserRecord {
590 username: "admin".to_string(),
591 password_hash: "y".to_string(),
592 locked: false,
593 },
594 PersistedUserRecord {
595 username: "opencoder".to_string(),
596 password_hash: "z".to_string(),
597 locked: true,
598 },
599 ];
600 let usernames = managed_usernames_from_records(&records);
601 assert_eq!(usernames, vec!["admin".to_string()]);
602 }
603
604 #[test]
605 fn test_user_info_struct() {
606 let info = UserInfo {
607 username: "admin".to_string(),
608 uid: 1001,
609 home: "/home/admin".to_string(),
610 shell: "/bin/bash".to_string(),
611 locked: false,
612 };
613 assert_eq!(info.username, "admin");
614 assert!(!info.locked);
615 }
616
617 #[test]
618 fn test_user_info_equality() {
619 let info1 = UserInfo {
620 username: "admin".to_string(),
621 uid: 1001,
622 home: "/home/admin".to_string(),
623 shell: "/bin/bash".to_string(),
624 locked: false,
625 };
626 let info2 = info1.clone();
627 assert_eq!(info1, info2);
628 }
629
630 #[test]
631 fn test_user_info_debug() {
632 let info = UserInfo {
633 username: "test".to_string(),
634 uid: 1000,
635 home: "/home/test".to_string(),
636 shell: "/bin/bash".to_string(),
637 locked: true,
638 };
639 let debug = format!("{info:?}");
640 assert!(debug.contains("test"));
641 assert!(debug.contains("1000"));
642 assert!(debug.contains("locked: true"));
643 }
644}