[Pkg-shadow-devel] [Git][debian/adduser][wip/various-random-things] 15 commits: Give chpasswd test values that it will accept

Marc Haber (@zugschlus) gitlab at salsa.debian.org
Sun Jan 11 07:09:21 GMT 2026



Marc Haber pushed to branch wip/various-random-things at Debian / adduser


Commits:
8e67780f by Marc Haber at 2026-01-11T07:41:25+01:00
Give chpasswd test values that it will accept

This is to work around the more picky chpasswd in new src:shadow

- - - - -
13c16e7e by Marc Haber at 2026-01-11T07:52:45+01:00
rework EXISTING_ and existing_user_status

this redefines the EXISTING variables
simplifies the EXISTING states (they were overengineered and redundant)
reworks existing_user_status, improving logic and output

The new variable values are also read by the test suites and need to be
in sync

Thanks: Matt Barry

- - - - -
f1a50e4f by Marc Haber at 2026-01-11T07:52:45+01:00
move Marc's test10.pl to test11.pl to make Room

Git-Dch: ignore

- - - - -
5d590845 by Marc Haber at 2026-01-11T07:52:45+01:00
add vim helper line to testsuite files

The files need to be reflowed when working on them next time

Git-Dch: ignore

- - - - -
4edf8108 by Marc Haber at 2026-01-11T07:52:45+01:00
prepare upstream testsuite for EXISTING_

Git-Dch: ignore

- - - - -
8e2114ab by Marc Haber at 2026-01-11T07:52:45+01:00
various fixes in lib_test.pm

Git-Dch: ignore

- - - - -
c3352ba9 by Marc Haber at 2026-01-11T07:52:45+01:00
rework runsuite.sh

This now allows running a single test, and cleans up better

Git-Dch: ignore

- - - - -
87897be5 by Marc Haber at 2026-01-11T07:52:45+01:00
make more clear that failure was expected

Git-Dch: ignore

- - - - -
b664e466 by Marc Haber at 2026-01-11T08:01:27+01:00
improve log level handling

Thanks: Matt Barry
Git-Dch: ignore

- - - - -
6747a884 by Marc Haber at 2026-01-11T08:04:43+01:00
clarify documentation of exit value 31

Git-Dch: ignore

- - - - -
a7efe29c by Marc Haber at 2026-01-11T08:04:43+01:00
rename to RET_INVALID_CHARS_IN_INPUT, apply to comment as well

That was RET_INVALID_CHARS_IN_INPUT previously. The check is now
applied to the comment as well and the error message adapted.

Git-Dch: ignore

- - - - -
f543541e by Marc Haber at 2026-01-11T08:04:43+01:00
move interactive command loops to a function

This is more streamlined and handles running on no terminal better

- - - - -
fc25dc72 by Marc Haber at 2026-01-11T08:04:43+01:00
re-work logic around remove-home etc

Git-Dch: ignore

- - - - -
cfe4f515 by Marc Haber at 2026-01-11T08:04:43+01:00
prepare testsuite libraries to properly handle EXISTING_

this brings the simplifications to the test suite libraries

Git-Dch: ignore

- - - - -
28c9faeb by Marc Haber at 2026-01-11T08:04:43+01:00
add assert_path_is_a_file to debian/tests/lib

Git-Dch: ignore

- - - - -


19 changed files:

- AdduserCommon.pm
- AdduserRetvalues.pm
- adduser
- debian/tests/f/adduser_system.t
- debian/tests/lib/AdduserTestsCommon.pm
- deluser
- doc/adduser.8
- testsuite/lib_test.pm
- testsuite/runsuite.sh
- testsuite/test01.pl
- testsuite/test02.pl
- testsuite/test03.pl
- testsuite/test04.pl
- testsuite/test05.pl
- testsuite/test06.pl
- testsuite/test07.pl
- testsuite/test08.pl
- testsuite/test09.pl
- testsuite/test10.pl → testsuite/test11.pl


Changes:

=====================================
AdduserCommon.pm
=====================================
@@ -102,6 +102,14 @@ use constant {
     EXISTING_ID_MISMATCH => 4,
     EXISTING_LOCKED => 8,
     EXISTING_HAS_PASSWORD => 16,
+    EXISTING_EXPIRED => 32,
+    EXISTING_NOLOGIN => 64,
+};
+
+use constant {
+    STDOUTDEFLEVEL => "warn",
+    STDERRDEFLEVEL => "warn",
+    LOGMSGDEFLEVEL => "info",
 };
 
 @EXPORT = (
@@ -138,6 +146,11 @@ use constant {
     'EXISTING_ID_MISMATCH',
     'EXISTING_LOCKED',
     'EXISTING_HAS_PASSWORD',
+    'EXISTING_EXPIRED',
+    'EXISTING_NOLOGIN',
+    'STDOUTDEFLEVEL',
+    'STDERRDEFLEVEL',
+    'LOGMSGDEFLEVEL',
     'existing_user_status',
     'existing_group_status',
 );
@@ -590,35 +603,47 @@ END {
 #   new_name: the name of the user to check
 #   new_uid : the UID of the user
 # return value:
-#   bitwise combination of these constants:
-#       EXISTING_NOT_FOUND => 0
-#       EXISTING_FOUND => 1
-#       EXISTING_SYSTEM => 2
-#       EXISTING_ID_MISMATCH => 4
-#       EXISTING_LOCKED => 8
-#       EXISTING_HAS_PASSWORD => 16
-#   e.g. if the requested account name exists as a locked system user,
-#   return 8|2|1 == 11
+#   bitwise combination of the EXISTING_ constants
 sub existing_user_status {
-    my ($config, $new_name,$new_uid) = @_;
-    my ($dummy1,$pw,$uid);
+    my ($config, $user_name,$user_uid) = @_;
     my $ret = EXISTING_NOT_FOUND;
-    log_trace( "existing_user_status called with new_name %s, new_uid %s, first_system_uid %s, last_system_uid %s", $new_name, $new_uid, $config->{"first_system_uid"}, $config->{"last_system_uid"} );
-    if (($dummy1,$pw,$uid) = egetpwnam($new_name)) {
+    log_trace( "existing_user_status called with user_name %s, user_uid %s, first_system_uid %s, last_system_uid %s", $user_name, $user_uid, $config->{"first_system_uid"}, $config->{"last_system_uid"} );
+
+    # collect user data
+    my (
+        $egpwn_name, $egpwn_passwd, $egpwn_uid, $egpwn_gid, $egpwn_quota,
+        $egpwn_comment, $egpwn_gcos, $egpwn_dir, $egpwn_shell, $egpwn_expire,
+        $egpwn_rest
+    ) = egetpwnam($user_name);
+    my $shadow_line = `getent shadow $user_name`;
+    chomp $shadow_line;
+    my @shadow_fields = split /:/, $shadow_line;
+    if (defined $egpwn_uid) {
         # user with the name exists
-        log_trace( "egetpwnam(%s) returns %s, %s, %s", $new_name, $dummy1, $pw, $uid );
+        log_trace( "egetpwnam(%s) returns %s, %s, %s, %s", $user_name, $egpwn_passwd, $egpwn_uid, $egpwn_dir, $egpwn_shell );
         $ret |= EXISTING_FOUND;
-        $ret |= EXISTING_ID_MISMATCH if (defined($new_uid) && $uid != $new_uid);
+        $ret |= EXISTING_ID_MISMATCH if (defined($user_uid) && $egpwn_uid != $user_uid);
         $ret |= EXISTING_SYSTEM if
-            (($uid >= $config->{"first_system_uid"}) && ($uid <= $config->{"last_system_uid"}));
+            (($egpwn_uid >= $config->{"first_system_uid"}) && ($egpwn_uid <= $config->{"last_system_uid"}));
         $ret |= EXISTING_HAS_PASSWORD if
-            (defined $pw && $pw ne '' && $pw ne '!' && $pw !~ /^\*/);
-        $ret |= EXISTING_LOCKED if (substr($pw,0,1) eq "!");  # TODO: also check expiry?
-    } elsif ($new_uid && getpwuid($new_uid)) {
+            (defined $egpwn_passwd && $egpwn_passwd ne '' && ($egpwn_passwd =~ s/^[!*]+//r ne ''));
+
+        my $password_field = $shadow_fields[1] // '';
+        $ret |= EXISTING_LOCKED if $password_field =~ /^[!*]/;
+
+        $ret |= EXISTING_NOLOGIN if ($egpwn_shell =~ /bin\/nologin/);
+
+        my $acct_exp = $shadow_fields[7] // '';
+        if ($acct_exp && $acct_exp > 0) {
+            my $today_days = int(time / 86400);
+            $ret |= EXISTING_EXPIRED if $acct_exp < $today_days;
+        }
+
+    } elsif (defined($user_uid) && getpwuid($user_uid)) {
         # user with the uid exists
         $ret |= EXISTING_ID_MISMATCH;
     }
-    log_trace( "existing_user_status( %s, %s ) returns %s", $new_name, $new_uid, $ret );
+    log_trace( "existing_user_status( %s, %s ) returns %s (%s)", $user_name, $user_uid, $ret, existing_value_desc($ret) );
     return $ret;
 }
 
@@ -634,23 +659,37 @@ sub existing_user_status {
 #       EXISTING_ID_MISMATCH => 4
 sub existing_group_status {
     my ($config, $new_name,$new_gid) = @_;
-    my ($dummy1,$dummy2,$gid);
+    my ($gid);
     my $ret = EXISTING_NOT_FOUND;
     log_trace( "existing_group_status called with new_name %s, new_gid %s", $new_name, $new_gid );
-    if (($dummy1,$dummy2,$gid) = egetgrnam($new_name)) {
+    if ((undef,undef,$gid) = egetgrnam($new_name)) {
         # group with the name exists
         log_trace("egetgrnam %s returned successfully, gid = %s", $new_name, $gid);
         $ret |= EXISTING_FOUND;
         $ret |= EXISTING_ID_MISMATCH if (defined($new_gid) && $gid != $new_gid);
         $ret |= EXISTING_SYSTEM if
             (($gid >= $config->{"first_system_gid"}) && ($gid <= $config->{"last_system_gid"}));
-    } elsif ($new_gid && getgrgid($new_gid)) {
+    } elsif (defined($new_gid) && getgrgid($new_gid)) {
         $ret |= EXISTING_ID_MISMATCH;
     }
-    log_trace( "existing_group_status( %s, %s ) returns %s", $new_name, $new_gid, $ret );
+    log_trace( "existing_group_status( %s, %s ) returns %s (%s)", $new_name, $new_gid, $ret, existing_value_desc($ret) );
     return $ret;
 }
 
+sub existing_value_desc {
+    my ($val) = @_;
+    my @flags = ();
+    push @flags, "found" if ($val & EXISTING_FOUND);
+    push @flags, "wrongid" if $val & EXISTING_ID_MISMATCH;
+    push @flags, "system" if ($val & EXISTING_SYSTEM);
+    push @flags, "locked" if $val & EXISTING_LOCKED;
+    push @flags, "haspass" if $val & EXISTING_HAS_PASSWORD;
+    push @flags, "nologin" if $val & EXISTING_NOLOGIN;
+    push @flags, "expired" if $val & EXISTING_EXPIRED;
+    push @flags, "notfound" unless $#flags > 0;
+    return join '|', at flags
+}
+
 1;
 
 # Local Variables:


=====================================
AdduserRetvalues.pm
=====================================
@@ -20,7 +20,7 @@ use vars qw(@EXPORT $VAR1);
     'RET_ID_IN_USE',
     'RET_NO_ID_IN_RANGE',
     'RET_NO_PRIMARY_GID',
-    'RET_INVALID_CHARS_IN_NAME',
+    'RET_INVALID_CHARS_IN_INPUT',
     'RET_INVALID_HOME_DIRECTORY',
     'RET_INVALID_NAME_FROM_USERADD',
     'RET_GROUP_NOT_EMPTY',
@@ -61,8 +61,8 @@ use constant RET_NO_PRIMARY_GID => 23; # requested primary GID does not exist
 
 # object name errors
 
-use constant RET_INVALID_CHARS_IN_NAME => 31; # the provided name contains invalid characters
-use constant RET_INVALID_HOME_DIRECTORY => 32; # the provided name contains invalid characters
+use constant RET_INVALID_CHARS_IN_INPUT => 31; # provided input (name/comment) contains invalid characters
+use constant RET_INVALID_HOME_DIRECTORY => 32; # the provided home directory is invalid
 use constant RET_INVALID_NAME_FROM_USERADD => 32; # useradd returned 19 "invalid user or group name"
 
 # group membership errors


=====================================
adduser
=====================================
@@ -103,9 +103,9 @@ $0 =~ s+.*/++;
 our $action;
 our $verbose;		# should we be verbose?
 my $name_check_level = 0;		# should we allow bad names?
-our $stdoutmsglevel = "warn";
-our $stderrmsglevel = "warn";
-our $logmsglevel = "info";
+our $stdoutmsglevel = undef;
+our $stderrmsglevel = undef;
+our $logmsglevel = undef;
 my $allow_badname = 0;		# should we allow bad names?
 my $ask_passwd = 1;		# ask for a passwd?
 my $disabled_login = 0;		# leave the new account disabled?
@@ -199,10 +199,11 @@ if (!@configfiles) {
 
 # make sure that message levels apply for reading configuration
 # this will be overridden again after reading configuration
-$stdoutmsglevel = sanitize_string($stdoutmsglevel);
-$stderrmsglevel = sanitize_string($stderrmsglevel);
-$logmsglevel = sanitize_string($logmsglevel);
-set_msglevel( $stderrmsglevel, $stdoutmsglevel, $logmsglevel );
+set_msglevel( 
+    sanitize_string($stderrmsglevel or STDERRDEFLEVEL),
+    sanitize_string($stdoutmsglevel or STDOUTDEFLEVEL),
+    sanitize_string($logmsglevel or LOGMSGDEFLEVEL),
+);
 log_trace("ARGV %s", join(@ARGV,"-"));
 log_trace("special_home %s", $special_home);
 log_trace("special_home %s", encode($charset, $special_home));
@@ -223,8 +224,13 @@ if( $> != 0) {
     log_fatal( mtx("Only root may add a user or group to the system.") );
     exit( RET_ROOT_NEEDED );
 }
-
-# TODO: Handle configuration file input, allow bare input there.
+# ARGV > adduser.conf > (default)
+$stdoutmsglevel //= $config{'stdoutmsglevel'};
+$stdoutmsglevel //= STDOUTDEFLEVEL;
+$stderrmsglevel //= $config{'stderrmsglevel'}; 
+$stderrmsglevel //= STDERRDEFLEVEL;
+$logmsglevel    //= $config{'logmsglevel'};
+$logmsglevel    //= LOGMSGDEFLEVEL;
 $stdoutmsglevel = sanitize_string($stdoutmsglevel);
 $stderrmsglevel = sanitize_string($stderrmsglevel);
 $logmsglevel = sanitize_string($logmsglevel);
@@ -235,6 +241,7 @@ if( defined $verbose ) {
     } elsif( $verbose == 1 ) {
         set_msglevel( $stderrmsglevel, "info", $logmsglevel );
     } elsif( $verbose == 2 ) {
+        set_msglevel( $stdoutmsglevel, "debug", $logmsglevel );
         set_msglevel( $stderrmsglevel, "debug", $logmsglevel );
     }
 }
@@ -364,7 +371,8 @@ if ( defined $comment_tainted ) {
     log_trace("check comment %s for unwanted chars", $special_home);
     # do not sanitize, can't be done without libperl
     if ( $comment_tainted !~ qr/^([^\x00-\x1F\x7F:]*)$/ ) {
-        die( "unwanted chars in comment" );
+        log_fatal( mtx("unwanted chars in comment") );
+        exit( RET_INVALID_CHARS_IN_INPUT );
     }
 }
 if( defined $special_shell ) {
@@ -403,8 +411,8 @@ if( defined $gid_option ) {
 }
 
 if ((defined($special_home)) && ($special_home !~ m+^/+ )) {
-  log_fatal( mtx("The home dir must be an absolute path.") );
-  exit( RET_INVALID_HOME_DIRECTORY );
+    log_fatal( mtx("The home dir must be an absolute path.") );
+    exit( RET_INVALID_HOME_DIRECTORY );
 }
 
 
@@ -942,49 +950,20 @@ if ($action eq "adduser") {
     create_homedir ($no_copy_skel ? 0 : 1, 0); # copy skeleton data
 
     # useradd without -p has left the account disabled (password string is '!')
-    my $yesexpr = langinfo(YESEXPR());
-    my $noexpr = langinfo(NOEXPR());
     if ($ask_passwd) {
-        PASSWD: for (;;) {
-            my $passwd = which('passwd');
-            my $ok = systemcall_or_warn($passwd, $new_name);
-            $ok = $ok >> 8;
-            log_debug( "systemcall_or_warn %s %s return value %s", $passwd, $new_name, $ok);
-            if ($ok != 0) {
-                my $answer;
-                # hm, error, should we break now?
-                if ($ok == 1) {
-                    log_warn( mtx("Permission denied"));
-                } elsif ($ok == 2) {
-                    log_warn( mtx("invalid combination of options"));
-                } elsif ($ok == 3) {
-                    log_warn( mtx("unexpected failure, nothing done"));
-                } elsif ($ok == 4) {
-                    log_warn( mtx("unexpected failure, passwd file missing"));
-                } elsif ($ok == 5) {
-                    log_warn( mtx("passwd file busy, try again"));
-                } elsif ($ok == 6) {
-                    log_warn( mtx("invalid argument to option"));
-                } elsif ($ok == 10) {
-                    log_warn( mtx("wrong password given or password retyped incorrectly"));
-                } else {
-                    log_warn( mtx("unexpected return code %s given from passwd"), $ok );
-                }
+        my %passwd_errors = (
+            1  => "Permission denied",
+            2  => "Invalid combination of options",
+            3  => "Unexpected failure, nothing done",
+            4  => "Passwd file missing",
+            5  => "Passwd file busy, try again",
+            6  => "Invalid argument to option",
+            10 => "Wrong password given or retyped incorrectly",
+        );
 
-                # Translators: [y/N] has to be replaced by values defined in your
-                # locale.  You can see by running "locale noexpr" which regular
-                # expression will be checked to find positive answer.
-                PROMPT: for (;;) {
-                    print (gtx("Try again? [y/N] "));
-                    chop ($answer=<STDIN>);
-                    last PROMPT if ($answer =~ m/$yesexpr/o);
-                    last PASSWD if ($answer =~ m/$noexpr/o);
-                    last PASSWD if (!$answer);
-                }
-            } else {
-                last; ## passwd ok
-            }
-        }
+        interactive_command_with_confirmation(
+            'passwd', $new_name, \%passwd_errors
+        );
     }
 
     if (defined($comment_tainted)) {
@@ -992,22 +971,14 @@ if ($action eq "adduser") {
     } elsif ($uid_pool{$new_name}{'comment'}) {
         ch_comment($new_name, $uid_pool{$new_name}{'comment'});
     } else {
-        my $noexpr = langinfo(NOEXPR());
-        my $yesexpr = langinfo(YESEXPR());
-        CHFN: for (;;) {
-            my $chfn = &which('chfn');
-            systemcall($chfn, $new_name);
-            # Translators: [y/N] has to be replaced by values defined in your
-            # locale.  You can see by running "locale yesexpr" which regular
-            # expression will be checked to find positive answer.
-            PROMPT: for (;;) {
-                print (gtx("Is the information correct? [Y/n] "));
-                chop (my $answer=<STDIN>);
-                last PROMPT if ($answer =~ m/$noexpr/o);
-                last CHFN if ($answer =~ m/$yesexpr/o);
-                last CHFN if (!$answer);
-            }
-        }
+        my %chfn_errors = (
+            1 => "Permission denied",
+            2  => "Invalid combination of options",
+        );
+
+        interactive_command_with_confirmation(
+            'chfn', $new_name, \%chfn_errors, 1
+        );
     }
 
     if ( ( $add_extra_groups || $config{"add_extra_groups"} ) && defined($config{"extra_groups"}) ) {
@@ -1309,21 +1280,21 @@ sub sanitize_name {
         # this check cannot be turned off
         log_err( mtx("To avoid ambiguity with numerical UIDs, usernames which" .
             "resemble numbers or negative numbers are not allowed.") );
-        exit( RET_INVALID_CHARS_IN_NAME );
+        exit( RET_INVALID_CHARS_IN_INPUT );
     }
 
     log_trace("sanitize_name testing single or double period");
     if ( $name =~ qr/^\.\.?$/ ) {
         # this check cannot be turned off
         log_err( mtx("Usernames must not be a single or a double period.") );
-        exit( RET_INVALID_CHARS_IN_NAME );
+        exit( RET_INVALID_CHARS_IN_INPUT );
     }
 
     log_trace("sanitize_name testing > 32 chars");
     if (length( encode($charset, $name) ) > 32) {
         # this check cannot be turned off
         log_err( mtx("Usernames must be no more than 32 bytes in length.") );
-        exit( RET_INVALID_CHARS_IN_NAME );
+        exit( RET_INVALID_CHARS_IN_INPUT );
     }
 
     log_trace("sanitize_name testing %s against insane chars %s", $name, def_min_regex);
@@ -1333,7 +1304,7 @@ sub sanitize_name {
             "dash, plus sign, or tilde, and it must not contain any of the" .
             "following: colon, comma, slash, or any whitespace characters" .
             "including spaces, tabs, and newlines.") );
-        exit( RET_INVALID_CHARS_IN_NAME );
+        exit( RET_INVALID_CHARS_IN_INPUT );
     }
 
     log_trace("sanitize_name checking %s against %s (%s)", $name, $name_regex, $name_regex_var);
@@ -1350,7 +1321,7 @@ sub sanitize_name {
             "compatibility with Samba machine accounts, \$ is also supported" .
             "at the end of the username.  (Use the `--allow-all-names' option" .
             "to bypass this restriction.)") );
-        exit( RET_INVALID_CHARS_IN_NAME );
+        exit( RET_INVALID_CHARS_IN_INPUT );
     }
 
     if ($name_check_level) {
@@ -1360,7 +1331,7 @@ sub sanitize_name {
             "configured via the %s configuration variable.  Use the" .
             "`--allow-bad-names' option to relax this check or reconfigure" .
             "%s in configuration."), $name_regex_var, $name_regex_var );
-        exit( RET_INVALID_CHARS_IN_NAME );
+        exit( RET_INVALID_CHARS_IN_INPUT );
     }
 
     log_trace("sanitize_name checking %s against %s", $name, anynamere);
@@ -1466,6 +1437,64 @@ sub user_is_member {
 }
 
 
+sub interactive_command_with_confirmation {
+    my ($cmd, $user_name, $error_map, $always_confirm) = @_;
+
+    $always_confirm //= 0;   # default to false if not provided
+    return unless -t STDIN;  # skip non-interactive
+
+    my $yesexpr = langinfo(YESEXPR);
+    my $noexpr  = langinfo(NOEXPR);
+
+    my $retry = 1;
+
+    while ($retry) {
+        $retry = 0;
+
+        my $full_cmd = which($cmd);
+        my $ok = systemcall_or_warn($full_cmd, $user_name);
+        $ok = $ok >> 8;  # extract exit code
+
+        log_debug("systemcall_or_warn %s %s return value %s", $full_cmd, $user_name, $ok);
+
+        if ($ok != 0 && $error_map) {
+            if (exists $error_map->{$ok}) {
+                log_warn($error_map->{$ok});
+            } else {
+                log_warn("Unexpected return code %s from %s", $ok, $cmd);
+            }
+        }
+
+        # Prompt for confirmation if requested
+        if ($always_confirm) {
+            my $answer;
+            do {
+                print gtx("Is the information correct? [Y/n] ");
+                $answer = <STDIN>;
+
+                unless (defined $answer) {
+                    warn "No input available, assuming 'yes'\n";
+                    last;
+                }
+
+                chomp($answer);
+                $answer =~ s/^\s+|\s+$//g;
+
+                if ($answer =~ m/$yesexpr/o || $answer eq '') {
+                    $retry = 0;  # accepted
+                    last;
+                } elsif ($answer =~ m/$noexpr/o) {
+                    $retry = 1;  # retry command
+                } else {
+                    print "Please answer yes or no.\n";
+                    $answer = undef;  # repeat prompt
+                }
+
+            } while (!defined $answer);
+        }
+    }
+}
+
 sub cleanup {
     if ($undohome) {
         log_info( mtx("Removing directory `%s' ..."), $undohome);


=====================================
debian/tests/f/adduser_system.t
=====================================
@@ -204,8 +204,10 @@ assert_command_success(
 assert_user_exists('aust');
 assert_user_is_system('aust');
 
-system('echo "aust:!foobar" | chpasswd --encrypted');
-# with #1099734 fixed, this should fail
+# $ mkpasswd --hash=yescrypt foobar
+# $y$j9T$dDqPXxXOCZL14/3jiuscW.$P8VGTWHqO1.qLOJs5Mas7Vzj3Ni9Es3QhACrVa0Z5Z3
+system(qq{echo 'aust:!\$y\$j9T\$dDqPXxXOCZL14/3jiuscW.\$P8VGTWHqO1.qLOJs5Mas7Vzj3Ni9Es3QhACrVa0Z5Z3' | chpasswd --encrypted});
+ok(1, "set passwd to !foobar");
 assert_command_result_silent(RET_WRONG_OBJECT_PROPERTIES,
     '/usr/sbin/adduser',
     '--stdoutmsglevel=error', '--stderrmsglevel=error',
@@ -216,23 +218,28 @@ assert_command_result_silent(RET_WRONG_OBJECT_PROPERTIES,
 assert_user_exists('aust');
 assert_user_is_system('aust');
 
-system('echo "aust:*foobar" | chpasswd --encrypted');
-ok(1, "set passwd to *foobar");
-assert_command_success(
-    '/usr/sbin/adduser',
-    '--stdoutmsglevel=error', '--stderrmsglevel=error',
-    '--system',
-    '--disabled-login',
-    'aust'
-);
-assert_user_exists('aust');
-assert_user_is_system('aust');
+# #1124835
+# $ mkpasswd --hash=yescrypt foobar
+# $y$j9T$dDqPXxXOCZL14/3jiuscW.$P8VGTWHqO1.qLOJs5Mas7Vzj3Ni9Es3QhACrVa0Z5Z3
+#system(qq{echo 'aust:*\$y\$j9T\$dDqPXxXOCZL14/3jiuscW.\$P8VGTWHqO1.qLOJs5Mas7Vzj3Ni9Es3QhACrVa0Z5Z3' | chpasswd --encrypted});
+#ok(1, "set passwd to *foobar");
+#assert_command_result_silent(RET_WRONG_OBJECT_PROPERTIES,
+#    '/usr/sbin/adduser',
+#    '--stdoutmsglevel=error', '--stderrmsglevel=error',
+#    '--system',
+#    '--disabled-login',
+#    'aust'
+#);
+#assert_user_exists('aust');
+#assert_user_is_system('aust');
+
 assert_command_success(
     '/usr/sbin/deluser',
     '--stdoutmsglevel=error', '--stderrmsglevel=error',
     '--system',
     'aust'
 );
+assert_user_does_not_exist('aust');
 
 # ref #100032
 # test --home


=====================================
debian/tests/lib/AdduserTestsCommon.pm
=====================================
@@ -30,6 +30,21 @@ END {
     if (-f '/var/cache/adduser/tests/state.tar');
 }
 
+use constant {
+    EXISTING_NOT_FOUND => 0,
+    EXISTING_FOUND => 1,
+    EXISTING_SYSTEM => 2,
+    EXISTING_ID_MISMATCH => 4,
+    EXISTING_LOCKED => 8,
+    EXISTING_HAS_PASSWORD => 16,
+    EXISTING_EXPIRED => 32,
+    EXISTING_NOLOGIN => 64,
+    SYS_MIN => 100,
+    SYS_MAX => 999,
+    USER_MIN => 1000,
+    USER_MAX => 9999,
+};
+
 my $charset = langinfo(CODESET);
 binmode(STDOUT, ":encoding($charset)");
 binmode(STDERR, ":encoding($charset)");
@@ -294,6 +309,7 @@ sub assert_path_has_ownership {
 sub assert_path_is_a_file {
     my $path = shift;
     ok(-f $path, "path is a file $path");
+    ok(-f $path, "path is a file: $path");
 }
 
 sub assert_path_is_a_directory {
@@ -459,6 +475,83 @@ sub apply_config_hash {
     close(CONF);
 }
 
+sub assert_user_status {
+    my ($username, $mask, $desc, $invert) = @_;
+
+    # Determine inversion from description if it starts with 'NOT '
+    if ($desc =~ /^NOT\s+(.*)/i) {
+        die "Cannot set invert=1 if description starts with NOT" if $invert;
+        $desc = $1;      # remove 'NOT ' prefix
+        $invert = 1;     # automatically invert
+    }
+
+    $invert //= 0;       # default to positive assertion
+
+    my $status = existing_user_status($username);
+    my $ok = ($status & $mask) == $mask;
+    $ok = !$ok if $invert;
+
+    my $message = $invert
+        ? "User '$username' is NOT $desc (status $status)"
+        : "User '$username' $desc (status $status)";
+
+    ok($ok, $message);
+}
+
+
+sub existing_user_status {
+    my ($user_name,$user_uid) = @_;
+    my $ret = EXISTING_NOT_FOUND;
+        my (
+        $egpwn_name, $egpwn_passwd, $egpwn_uid, $egpwn_gid, $egpwn_quota,
+        $egpwn_comment, $egpwn_gcos, $egpwn_dir, $egpwn_shell, $egpwn_expire,
+        $egpwn_rest
+    ) = getpwnam($user_name);
+
+    if (defined $egpwn_uid) {
+        $ret |= EXISTING_FOUND;
+        $ret |= EXISTING_ID_MISMATCH if (defined($user_uid) && $egpwn_uid != $user_uid);
+        $ret |= EXISTING_SYSTEM if \
+            ($egpwn_uid >= SYS_MIN && $egpwn_uid <= SYS_MAX);
+
+        $ret |= EXISTING_NOLOGIN if ($egpwn_shell =~ /bin\/nologin/);
+        $ret |= EXISTING_HAS_PASSWORD if
+            (defined $egpwn_passwd && $egpwn_passwd ne '' && ($egpwn_passwd =~ s/^[!*]+//r ne ''));
+        $ret |= EXISTING_LOCKED if
+            (defined $egpwn_passwd && $egpwn_passwd =~ /^[!*]/);
+
+        my $age = `chage -l $user_name`;
+        if ($age =~ /Account expires\s*:\s*(.+)/i) {
+            my $exp = $1;
+            use POSIX qw(strftime);
+            use Time::Local;
+            if ($exp ne 'never') {
+                my $expiry_epoch = eval { `date -d "$exp" +%s` };
+                my $now = time;
+                $ret |= EXISTING_EXPIRED if ($expiry_epoch < $now);
+            }
+        }
+    } elsif ($user_uid && getpwuid($user_uid)) {
+        $ret |= EXISTING_ID_MISMATCH;
+    }
+    return $ret;
+}
+
+sub existing_group_status {
+    my ($group_name,$group_gid) = @_;
+    my $gid;
+    my $ret = EXISTING_NOT_FOUND;
+    if ((undef,undef,$gid) = egetgrnam($group_name)) {
+        $ret |= EXISTING_FOUND;
+        $ret |= EXISTING_ID_MISMATCH if (defined($group_gid) && $gid != $group_gid);
+        $ret |= EXISTING_SYSTEM if \
+            ($gid >= SYS_MIN && $gid <= SYS_MAX);
+    } elsif ($group_gid && getgrgid($group_gid)) {
+        $ret |= EXISTING_ID_MISMATCH;
+    }
+    return $ret;
+}
+
 1;
 
 # vim: tabstop=4 shiftwidth=4 expandtab


=====================================
deluser
=====================================
@@ -265,21 +265,28 @@ if(defined($group)) {
 if($action eq "deluser") {
     my($name, $passwd, ,$uid, $rest);
 
+    if (($config{remove_home} || $config{remove_all_files} || $config{backup}) && ($install_more_packages)) {
+        log_warn( mtx("In order to use the --remove-home, --remove-all-files, and --backup features, you need to install the `perl' package. To accomplish that, run apt-get install perl.") );
+        $config{remove_home}=undef;
+        $config{remove_all_files}=undef;
+        $config{backup}=undef;
+        $config{backup_to}=undef;
+        exit ( RET_MORE_PACKAGES) unless( $config{"system"} );
+    }
+
+    my (
+        $egpwn_name, $egpwn_passwd, $egpwn_uid, $egpwn_gid, $egpwn_quota,
+        $egpwn_comment, $egpwn_gcos, $egpwn_dir, $egpwn_shell, $egpwn_expire,
+        $egpwn_rest
+    ) = egetpwnam(encode($charset, $user));
+
     # Don't allow a non-system user to be deleted when --system is given
     # Also, "user does not exist" is only a warning with --system, but an
     # error without --system.
     if( $config{"system"} ) {
-        if (($config{remove_home} || $config{remove_all_files} || $config{backup}) && ($install_more_packages)) {
-            log_warn( mtx("In order to use the --remove-home, --remove-all-files, and --backup features, you need to install the `perl' package. To accomplish that, run apt-get install perl.") );
-            $config{remove_home}=undef;
-            $config{remove_all_files}=undef;
-            $config{backup}=undef;
-            $config{backup_to}=undef;
-        }
-
-        if( ($name, $passwd, $uid, $rest) = egetpwnam(encode($charset, $user)) ) {
-            if ( ($uid < $config{"first_system_uid"} ||
-                $uid > $config{"last_system_uid" } ) ) {
+        if( defined $egpwn_uid ) {
+            if ( ($egpwn_uid < $config{"first_system_uid"} ||
+                $egpwn_uid > $config{"last_system_uid" } ) ) {
                 log_warn( mtx("The user `%s' is not a system user. Exiting."), $user);
                 exit( RET_WRONG_OBJECT_PROPERTIES );
             }
@@ -287,14 +294,9 @@ if($action eq "deluser") {
             log_info( mtx("The user `%s' does not exist, but --system was given. Exiting."), $user);
             exit( RET_OK );
         }
-    } else {
-        if (($config{remove_home} || $config{remove_all_files} || $config{backup}) && ($install_more_packages)) {
-            log_fatal( mtx("In order to use the --remove-home, --remove-all-files, and --backup features, you need to install the `perl' package. To accomplish that, run apt-get install perl.") );
-            exit( RET_MORE_PACKAGES );
-        }
     }
 
-    unless(exist_user($user)) {
+    unless(defined $egpwn_uid) {
         log_fatal( mtx("The user `%s' does not exist."), $user );
         exit( RET_OBJECT_DOES_NOT_EXIST );
     }


=====================================
doc/adduser.8
=====================================
@@ -613,8 +613,8 @@ There is no group with the requested GID for the primary group
 for a new user.
 .TP
 .B 31
-The chosen name for a new user or a new group does not conform to
-the selected naming rules.
+The chosen name or comment for a new user or a new group
+does not conform to the selected naming rules.
 .TP
 .B 32
 The home directory of a new user must be an absolute path.


=====================================
testsuite/lib_test.pm
=====================================
@@ -16,12 +16,15 @@ preseed_config(\@deluserconf,\%del_config);
 
 my $user_prefix = "addusertest";
 
-
+use constant {
+    SYS_MIN => 100,
+    SYS_MAX => 999,
+};
 
 sub assert {
   my ($cond) = @_;
   if ($cond) {
-    print "Test failed; aborting test suite\n";
+    print "Test failed\n";
     exit 1;
   }
 }
@@ -46,7 +49,7 @@ sub find_unused_uid {
   }
   else {
     print "Haven't found a unused uid in range ($low_uid - $high_uid)\nExiting ...\n";
-    exit 1;
+    return 1;
   }
 }
 
@@ -90,7 +93,7 @@ sub find_unused_gid {
   }
   else {
     print "Haven't found a unused gid in range ($low_gid - $high_gid)\nExiting ...\n";
-    exit 1;
+    return 1;
   }
 }
 
@@ -102,12 +105,13 @@ sub check_user_exist {
   my @ent = getpwnam ($username);
   if (!@ent) {
 	print "user $username does not exist\n";
-	exit 1;
+	return 1;
   }
   if (( defined($uid)) && ($ent[2] != $uid)) {
 	printf "uid $uid does not match %s\n",$ent[2];
 	return 1;
   }
+  print "user $username exists\n";
   return 0;
 }
 
@@ -115,8 +119,10 @@ sub check_user_not_exist {
   my ($username) = @_;
 
   if (defined(getpwnam($username))) {
+    print "user $username exists\n";
     return 1;
   }
+  print "user $username does not exist\n";
   return 0;
 }
 
@@ -228,5 +234,182 @@ sub check_user_has_gid {
   return 1;
 }
 
+sub testsuite_existing_user_status {
+    my ($user_name,$user_uid) = @_;
+    my $ret = EXISTING_NOT_FOUND;
+
+    my (
+        $egpwn_name, $egpwn_passwd, $egpwn_uid, $egpwn_gid, $egpwn_quota,
+        $egpwn_comment, $egpwn_gcos, $egpwn_dir, $egpwn_shell, $egpwn_expire,
+        $egpwn_rest
+    ) = getpwnam($user_name);
+
+    if (defined $egpwn_uid) {
+        $ret |= EXISTING_FOUND;
+        $ret |= EXISTING_ID_MISMATCH if (defined($user_uid) && $egpwn_uid != $user_uid);
+        $ret |= EXISTING_SYSTEM if \
+            ($egpwn_uid >= SYS_MIN && $egpwn_uid <= SYS_MAX);
+
+        $ret |= EXISTING_NOLOGIN if ($egpwn_shell =~ /bin\/nologin/);
+        $ret |= EXISTING_HAS_PASSWORD if
+            (defined $egpwn_passwd && $egpwn_passwd ne '' && ($egpwn_passwd =~ s/^[!*]+//r ne ''));
+        $ret |= EXISTING_LOCKED if
+            (defined $egpwn_passwd && $egpwn_passwd =~ /^[!*]/);
+
+        # this is deliberately implemented differently from the actual program
+        my $age = `chage -l $user_name`;
+
+        if ($age =~ /Account expires\s*:\s*(.+)/i) {
+            my $exp = $1;
+            if ($exp ne 'never') {
+                chomp $exp;
+                # Convert to epoch using GNU date
+                # Convert to epoch using GNU date
+                my $expiry_epoch = `date -d "$exp" +%s 2>/dev/null`;
+                chomp $expiry_epoch;
+
+                if (defined $expiry_epoch && $expiry_epoch =~ /^\d+$/) {
+                    $ret |= EXISTING_EXPIRED if ($expiry_epoch < time);
+                } else {
+                    warn "Failed to parse expiry date '$exp' with date command\n";
+                }
+            }
+        }
+    } elsif ($user_uid && getpwuid($user_uid)) {
+        $ret |= EXISTING_ID_MISMATCH;
+    }
+    return $ret;
+}
+
+# Map human-readable status names to bitmask constants
+my %USER_STATUS_MASK = (
+    locked      => EXISTING_LOCKED,
+    haspasswd   => EXISTING_HAS_PASSWORD,
+    nologin     => EXISTING_NOLOGIN,
+    expired     => EXISTING_EXPIRED,
+);
+sub check_user_status {
+    my ($username, $check, $do_print) = @_;
+    $do_print //= 0;
+
+    my $invert = 0;
+    my $result;
+
+    # Check for negative prefix "not_"
+    if ($check =~ /^not_(.+)$/) {
+        $invert = 1;
+        $check = $1;
+    }
+
+    my $mask = $USER_STATUS_MASK{$check}
+        or die "Unknown user status '$check'";
+
+    my $status = testsuite_existing_user_status($username);
+    # returns 0 if status is as desired so that it can be used in assertion
+    $result = (($status & $mask) == $mask) ? 0 : 1;
+
+    if ($do_print) {
+        my $msg = $result
+                ? "User '$username' $check"
+                : "User '$username' NOT $check";
+        print "$msg";
+    }
+
+    $result = !$result if $invert;
+    print " (status $status, returning ", $result ? 1 : 0, ")\n";
+    return $result;
+}
+
+
+
+sub testsuite_existing_user_status {
+    my ($user_name,$user_uid) = @_;
+    my $ret = EXISTING_NOT_FOUND;
+
+    my (
+        $egpwn_name, $egpwn_passwd, $egpwn_uid, $egpwn_gid, $egpwn_quota,
+        $egpwn_comment, $egpwn_gcos, $egpwn_dir, $egpwn_shell, $egpwn_expire,
+        $egpwn_rest
+    ) = getpwnam($user_name);
+
+    if (defined $egpwn_uid) {
+        $ret |= EXISTING_FOUND;
+        $ret |= EXISTING_ID_MISMATCH if (defined($user_uid) && $egpwn_uid != $user_uid);
+        $ret |= EXISTING_SYSTEM if \
+            ($egpwn_uid >= SYS_MIN && $egpwn_uid <= SYS_MAX);
+
+        $ret |= EXISTING_NOLOGIN if ($egpwn_shell =~ /bin\/nologin/);
+        $ret |= EXISTING_HAS_PASSWORD if
+            (defined $egpwn_passwd && $egpwn_passwd ne '' && ($egpwn_passwd =~ s/^[!*]+//r ne ''));
+        $ret |= EXISTING_LOCKED if
+            (defined $egpwn_passwd && $egpwn_passwd =~ /^[!*]/);
+
+        # this is deliberately implemented differently from the actual program
+        my $age = `chage -l $user_name`;
+
+        if ($age =~ /Account expires\s*:\s*(.+)/i) {
+            my $exp = $1;
+            if ($exp ne 'never') {
+                chomp $exp;
+                # Convert to epoch using GNU date
+                my $expiry_epoch = `date -d "$exp" +%s 2>/dev/null`;
+                chomp $expiry_epoch;
+
+                if (defined $expiry_epoch && $expiry_epoch =~ /^\d+$/) {
+                    $ret |= EXISTING_EXPIRED if ($expiry_epoch < time);
+                } else {
+                    warn "Failed to parse expiry date '$exp' with date command\n";
+                }
+            }
+        }
+    } elsif ($user_uid && getpwuid($user_uid)) {
+        $ret |= EXISTING_ID_MISMATCH;
+    }
+    return $ret;
+}
+
+# Map human-readable status names to bitmask constants
+my %USER_STATUS_MASK = (
+    locked      => EXISTING_LOCKED,
+    haspasswd   => EXISTING_HAS_PASSWORD,
+    nologin     => EXISTING_NOLOGIN,
+    expired     => EXISTING_EXPIRED,
+);
+
+sub check_user_status {
+    my ($username, $check, $do_print) = @_;
+    $do_print //= 0;
+
+    my $invert = 0;
+    my $result;
+
+    # Check for negative prefix "not_"
+    if ($check =~ /^not_(.+)$/) {
+        $invert = 1;
+        $check = $1;
+    }
+
+    my $mask = $USER_STATUS_MASK{$check}
+        or die "Unknown user status '$check'";
+
+    my $status = testsuite_existing_user_status($username);
+    # returns 0 if status is as desired so that it can be used in assertion
+    $result = (($status & $mask) == $mask) ? 0 : 1;
+
+    if ($do_print) {
+        my $msg = $result
+                ? "User '$username' $check"
+                : "User '$username' NOT $check";
+        print "$msg";
+    }
+
+    $result = !$result if $invert;
+    print " (status $status, returning ", $result ? 1 : 0, ")\n";
+    return $result;
+}
+
+
 
 return 1
+
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/runsuite.sh
=====================================
@@ -1,39 +1,63 @@
 #!/bin/bash
+set -euo pipefail
 
-FAILED=""
+# Backup directory
+BACKUP_DIR="./etc_backup"
+mkdir -p "$BACKUP_DIR"
 
-PASSWD_BAK="./passwd.backup"
+FILES=(passwd shadow group gshadow)
+FAILED=()
 
-
-if [ "$(id -u)" != "0" ]; then
-  echo "root needed"
+# Ensure root
+if [ "$(id -u)" -ne 0 ]; then
+  echo "root needed" >&2
   exit 1
 fi
 
-cp /etc/passwd $PASSWD_BAK
+# Backup critical files
+for f in "${FILES[@]}"; do
+  cp "/etc/$f" "$BACKUP_DIR/$f"
+done
 
+# Restore on exit
+cleanup() {
+  echo "Restoring original /etc files..."
+  for f in "${FILES[@]}"; do
+    cp "$BACKUP_DIR/$f" "/etc/$f"
+  done
+  rm -rf $BACKUP_DIR
+}
+trap cleanup EXIT
+
+# Determine tests to run
+if [ $# -ge 1 ]; then
+  TESTS=("$1")
+else
+  TESTS=(./test*.pl)
+fi
+
+# Run tests with shadowconfig set to "on" only
 for a in on; do
-  for i in ./test*.pl ; do
-    if ! shadowconfig $a > /dev/null; then
-      echo "shadowconfig $a failed"
-      exit 1
-    fi
+  if ! shadowconfig "$a" >/dev/null; then
+    echo "shadowconfig $a failed" >&2
+    exit 1
+  fi
+
+  for i in "${TESTS[@]}"; do
     echo
     echo "Starting $i (shadow $a)"
-    /usr/bin/perl -I. $i
-    if [ "$?" != "0" ]; then
-      FAILED="$FAILED $i($a)"
+    if ! perl -I. "$i" < /dev/null; then
+      FAILED+=("$i($a)")
     fi
   done
 done
 
-if [ -z "$FAILED" ]; then
+# Success/failure reporting
+if [ "${#FAILED[@]}" -eq 0 ]; then
   echo "All tests passed successfully"
-  rm $PASSWD_BAK
-  exit 0
 else
-  echo "tests $FAILED failed"
-  echo "see $PASSWD_BAK for a copy of /etc/passwd before starting"
+  echo "Tests failed: ${FAILED[*]}"
+  echo "Original /etc files were restored from $BACKUP_DIR"
   exit 1
 fi
 


=====================================
testsuite/test01.pl
=====================================
@@ -42,3 +42,4 @@ if (defined (getpwnam($username))) {
 	print "ok\n";
 }
 
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test02.pl
=====================================
@@ -127,9 +127,9 @@ if (defined (getpwnam($username))) {
             if( $error == 56 ) {
                 `deluser $username`;
             } else {
-                print "failed\n  deluser (file::find not present) returned an errorcode != 0/56 ($error)\n";
+                print "failed (expected)\n  deluser (file::find not present) returned an errorcode != 0/56 ($error)\n";
             }
-            print "failed\n  deluser (file::find not present) returned an errorcode != 0 ($error)\n";
+            print "failed (expected)\n  deluser (file::find not present) returned an errorcode != 0 ($error)\n";
             $error=0;
             `rm -rf $homedir`;
         }


=====================================
testsuite/test03.pl
=====================================
@@ -30,4 +30,5 @@ if (!defined (getpwnam($username))) {
 	assert(check_homedir_not_exist($homedir));	
 	print "ok\n";
 }
-  
+
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test04.pl
=====================================
@@ -30,4 +30,5 @@ if (!defined (getpwnam($username))) {
 	assert(check_user_in_group($username,$groupname));
 	print "ok\n";
 }
-  
+
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test05.pl
=====================================
@@ -29,4 +29,5 @@ if (!defined (getpwnam($username))) {
 	assert(check_user_in_group($username,$groupname));
 	print "ok\n";
 }
-  
+
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test06.pl
=====================================
@@ -28,4 +28,5 @@ if (!defined (getpwnam($username))) {
 	assert(check_user_has_gid($username,$want_gid));
 	print "ok\n";
 }
-  
+
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test07.pl
=====================================
@@ -30,4 +30,5 @@ if (!defined (getpwnam($username))) {
 	assert(check_user_homedir_not_exist ($username));
 	print "ok\n";
 }
-  
+
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test08.pl
=====================================
@@ -137,3 +137,5 @@ if (!defined (getpwnam($sysusername))) {
         }
 	print "ok\n";
 }
+
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test09.pl
=====================================
@@ -121,3 +121,4 @@ if ($output !~ /^fatal: The group `addusertest\d+' already exists\.$/ ) {
 }
 print "ok\n";
 
+# vim: tabstop=4 shiftwidth=4 expandtab


=====================================
testsuite/test10.pl → testsuite/test11.pl
=====================================



View it on GitLab: https://salsa.debian.org/debian/adduser/-/compare/432432f70ef45e7ed595edb63d89f24be34957c2...28c9faebb2b6fd6d660baf128b2b65094c7aa25a

-- 
View it on GitLab: https://salsa.debian.org/debian/adduser/-/compare/432432f70ef45e7ed595edb63d89f24be34957c2...28c9faebb2b6fd6d660baf128b2b65094c7aa25a
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-shadow-devel/attachments/20260111/7a6eb70e/attachment-0001.htm>


More information about the Pkg-shadow-devel mailing list