#!/usr/bin/perl -w

# Copyright: 2024-2025 Lorenzo Puliti <plorenzo@disroot.org>
# License: BSD-3-clause

# minsysusers version: 0.2

use 5.012;
use strict;
use warnings;

sub usage {print STDERR "usage: minsysusers [--root=/path]  file.conf [ file2.conf ... ]\n"; return 1;}
sub error {print STDERR "error:  $_[0]\n"; return 1;}
sub warning {print  "warning:  $_[0]\n"; return 0;}
#TODO standardize error for invalid field with a sub

unless (@ARGV) {exit usage;}
my $changeroot = 0; my $rcounter = 0; my $usergroup = 0; my $debug = 0;
my $root="";
my $conf;
my $prefix='--prefix=/';
my $name; my $id; my $gecos=""; my $home='/'; my $shell='/sbin/nologin';
my $gid; my $groupname; my $groupid; #TODO RM groupid
my $upath="0"; my $gpath="0";
my $rmin; my $rmax; my $range=""; my $lrange=""; my @rmins = (); my @rmaxs = (); #TODO RM range lrange?
my @conffiles = ();
my @args = ();
my @ulines = (); my @glines = (); my @mlines = (); my @rlines = ();

while (@ARGV) {
    my $arg = shift @ARGV;
    #print "arg is $arg\n";#debug
    if ($arg=~/^--root=(.*)$/)  {
        exit usage if "$changeroot";
        $changeroot = 1; #TODO: more than one root= are possible with systemd-sysusers
        $root = $1 ;
        }
    elsif ($arg=~/^--root$/) {
        exit usage if "$changeroot";
        $changeroot = 1 ; $arg = shift @ARGV; $root = $arg;
        }
    elsif ($arg=~/^-(.*)$/) {
        print STDERR "Unknown option: $arg\n";
        exit usage;
        }
    else {
        push(@conffiles, $arg);
        last unless (@ARGV)
        }
    }

unless (@conffiles) {exit usage;}
if ($changeroot) {
    #print "change root requested\n"; print "root is $root\n";#debug
    if ( ! -d "$root") {exit error("root directory not found:");}
    $prefix = "--prefix=$root";
    }
#print "conffiles are @conffiles \n";#debug

if ( -e "/etc/minsysusers.debug") {$debug = 1;}

sub is_reserved {# is_reserved($id)
    my $num = "$_[0]"; return 0 unless ($num =~ /^[0-9]+$/);# ok, not numeric (groupname)
    if ( "$num" eq "" )  { return 0;  };#ok, blank
    if ( "$num" =='65535' or "$num" =='4294967295' )  { return 1;  };#reserved
    return 0; #ok
    }

sub is_number {# is_number($var)
     my $num = "$_[0]";
     if ($num =~ /^[0-9]+$/) {return 1}; return 0;
    }

sub is_path {# is_path($var)  #approximated with "starts with /"
     my $slash = "$_[0]";
     if ($slash =~ /^\/(.*)$/) {return 1}; return 0;
    }

sub set_home_shell {#set_home_shell(fieldhome, fieldshell)
    if (length "$_[0]") {  $home="$_[0]" unless ("$_[0]" eq "-"); } #WARN: uninitialized value $_[0] with gecos as - and home unset
    if (length "$_[1]") {  $shell="$_[1]" unless ("$_[1]" eq "-"); }
    exit error ("u: $home: path expected") unless (is_path($home));
    exit error ("u: $shell: path expected") unless (is_path($shell));
    #if (defined "$_[0]") {  $home="$_[0]" unless ("$_[0]" eq "-"); }
    #if (defined "$_[1]") {  $shell="$_[1]" unless ("$_[1]" eq "-"); }
    }

sub match_name {#match_name(file, name) looks in 1st field of /etc/passwd ||/etc/group for a name match
    my $file = "$_[0]"; my $pattern = "$_[1]";
    open (my $F, '<', "$file") or die "failed to open $file: $!";
    while (my $fline = <$F>) {
        if ($fline =~ /^\Q$pattern\E:(.*)$/) {
            if ($debug) {print " $file: $pattern matched in $fline ";}#debug
            close $F; return 1
            }
        }
    close $F; return 0
    }

sub match_uid {#match_uid(file, uid/gid): looks in 3rd field of /etc/passwd || /etc/group for a uid || gid match
    my $file = "$_[0]"; my $pattern = "$_[1]"; #print " pattern is $pattern\n"; print "file is $file\n ";#debug
    open (my $F, '<', "$file") or die "failed to open $file: $!";
    while (my $fline = <$F>) {
        if ($fline =~ /^(.*):(.*):\Q$pattern\E:(.*)$/) {
            if ($debug) {print " $file: $pattern matched in $fline ";}#debug
            close $F; return 1
            }
        }
    close $F; return 0
    }

for (@conffiles) {
    my $usrconf = $_;
    if ( -f "$root/etc/sysusers.d/$usrconf") {
        $conf="$root/etc/sysusers.d/$usrconf";
        }
    elsif ( -l "$root/etc/sysusers.d/$usrconf") {
        my $symtarget = readlink "$root/etc/sysusers.d/$usrconf";
        if ($symtarget eq '/dev/null') {warning ("$usrconf: skipping, disabled via /dev/null link"); next;};
        }
    elsif ( -f "$root/usr/lib/sysusers.d/$usrconf") {
        $conf="$root/usr/lib/sysusers.d/$usrconf";
        }
    else {
        print STDERR "$usrconf : file not found\n"; exit 1;
        }
    #print "selected conffile path is $conf\n";#debug
    open(my $fh, '<', "$conf") or do { exit error ("failed to open $conf"); };
        while( my $line = <$fh>) {
            chomp $line;  my @types = split " " , $line, 2;
            if ($types[0]) {
                for($types[0]) {
                    if (/^#(.*)$/)       { next; print "comment line, skipping\n" }
                    elsif (/^u$/ or /^u!$/)       {# create user and group (account disabled) if they do not exist
                                                push(@ulines, $line);
                        }
                    elsif (/^g$/)       {# create system group, no pwd set, if it does not exist
                                                push(@glines, $line);
                        }
                    elsif (/^m$/)       {#create user or group if they do not exist
                                                push(@mlines, $line);
                        }
                    elsif (/^r$/)         {# tweak SYSGID/UID pool range
                                                push(@rlines, $line);
                        }
                    else                     { warning ("invalid line: $line") }
                    }
                }
            }
    close $fh;

    if (@rlines) {#TODO: systemd-sysuser:  with more than one of r lines multiple ranges are used, starting from higher value, but
                        #           this is not feasible with useradd groupadd soo.. we take only one r line an discards the rest with warnings?
                        # maybe if the call to useradd fails we can try another range if there is more than one..
        for (@rlines) {
            if ($rcounter) { warning ("r line ignored, only one line supported");} ;
            $rcounter = 1;#NOTE: can't pass multiple ranges to useradd/groupadd, so accept only the first one, stash the rest
            my $rline = $_;  my @fields = split " " , $rline;
            if ($debug) {print "line is: $rline\n";}#debug
            $name = $fields[1];  exit error ("r type: invalid name field, - expected") unless ($name eq "-");
            $id = $fields[2]; if ($id eq "" || $id eq "-") { exit error ("r type: missing or invalid id field");}
            if ($id =~ /^([0-9]+)-([0-9]+)$/) { #range
                $rmin=$1; $rmax=$2;
                }
            elsif (is_number($id)) { #num  # NOTE: overridden by each line id; otherwise fails if there are no available uid/gid left!
                $rmin=$id; $rmax=$id;
                }
            else { exit error ("r type: invalid id field, digit expected")}
            if (is_reserved($rmin)) { exit error (" invalid value: id 65535  and 4294967295 are reserved")};
            if (is_reserved($rmax)) { exit error (" invalid value: id 65535  and 4294967295 are reserved")};
            push(@rmins, $rmin); push(@rmaxs, $rmax);
            #####$range= "-K SYS_UID_MIN=$rmin -K SYS_UID_MAX=$rmax -K SYS_GID_MIN=$rmin -K SYS_GID_MAX=$rmax"; #TODO RM
            if ($debug) {print "name is: $name\n"; print "range is: $range\n"; print "rmin is: $rmin\n"; print "rmax is: $rmax\n";}#debug
            $name =""; $id=""; $rmin=""; $rmax=""; #@fields = ();
            if ($debug) {print "\n"; print "\n";}#debug
            }
        }
    if (@glines) {
        for (@glines) {
            my $gline = $_;  my @fields = split " " , $gline;
            if ($debug) {print "line is: $gline\n";}#debug
            $name = $fields[1]; if ($name eq "" || $name eq "-") { exit error ("u type: missing or invalid name field");}
            #print "name is $name\n";#debug
            if (match_name("$root/etc/group", "$name")) {print "$name group already exists, skipping line\n"; next;}
            #ID NOTE: id: if defined, can be gid (numeric) or /path/to/fileordir (no uid:gid, no groupname for g lines)
            if (defined $fields[2]) {
                if ($fields[2] eq "-") { # auto alloc of gid
                    $id="";
                    }
                elsif (is_number($fields[2])) { #numeric gid
                    $id=$fields[2];
                    if (match_uid("$root/etc/group", "$id")) {$id="";} #gid already taken, fallback to auto alloc
                    }
                elsif (is_path($fields[2])) { #path
                    $id = (stat("$root/$fields[2]"))[5]; #NOTE: [4] = uid, [5]= gid, use gid for g lines
                    if ( defined $id) { #stat returns empty list if it falis
                        if (match_uid("$root/etc/group", "$id")) {$id="";} #gid already taken, fallback to auto alloc
                        }
                    else {$id="";}#NOTE: path not found/stat failed, fallback to auto id allocaltion
                    }
                else {exit error ("g: id: invalid field value $fields[2]");}
                }
            else { $id=""; }
            if (is_reserved($id)) { exit error ("invalid value: id 65535  and 4294967295 are reserved")}
            if ($debug) {print "name: $name\n"; print "id: $id\n";}#debug
            if ($debug) {print "COMMAND is: \n";}#debug
            if ($id ne "") { #NOTE: line id prevails over r line range
                if ($debug) {print "groupadd $prefix -r -g$id $name\n"};
                system('groupadd', "$prefix", '-r', "-g$id", "$name");
                }
            elsif ($rcounter) {
                if ($debug) {print "groupadd $prefix -r -KSYS_GID_MIN=$rmins[0] -KSYS_GID_MAX=$rmaxs[0] $name\n"};
                system('groupadd', "$prefix", '-r', "-KSYS_GID_MIN=$rmins[0]", "-KSYS_GID_MAX=$rmaxs[0]", "$name");
                }
            else {
                if ($debug) {print "groupadd $prefix -r $name\n"};
                system('groupadd', "$prefix", '-r', "$name");
                }
            $name =""; $id=""; $gid=""; $lrange=""; #@fields = ();
            if ($debug) {print "\n"; print "\n";}#debug
            }
        }
    if (@ulines) {
        for (@ulines) {
            my $uline = $_;  my @fields = split " " , $uline;
            if ($debug) {print "line is: $uline\n";}#debug
            $name = $fields[1]; if ($name eq "" || $name eq "-") { exit error ("u type: missing or invalid name field");}
            #NOTE: we don't check if group name exists, group invocation is just skipped if user name exist
            if (match_name("$root/etc/passwd", "$name")) {print "$name user already exists, skipping line\n"; next;}
            if ($name eq "root") {$id="0"; $home = "/root"; $shell="/bin/sh"}#NOTE change default for root special case
            # ID can be: uid(numeric); uid:gid (numeric); uid(numeric):groupname; /path/to/fileordir--> get both uid and gid
            if (defined $fields[2]) {
                if ($fields[2] eq "-") { #auto allocation
                                     $id="";$gid=""; $lrange="$range"; $usergroup = 0;
                    }
                elsif (is_number($fields[2])) { #uid (numeric)
                                        $id=$fields[2]; $gid="";$lrange="-u $id"; $usergroup = 0;
                                        if (match_uid("$root/etc/passwd", "$id")) {$id=""; $gid=""; $lrange="$range"; $usergroup = 0;} #id already taken in system, fallback to auto allocation
                    }
                elsif ($fields[2] =~ /^(.*):(.*)$/) { # uid:gid || uid:groupname || -:gid || -:groupname
                                    $id=$1 ; $gid = $2; $usergroup = 0; #GID/groupname must already exist
                                    #GID
                                    if (is_number($gid)) {
                                        exit error ("u: $gid: gid not found in system") unless (match_uid("$root/etc/group", "$gid"));
                                    }
                                    else  { #groupname
                                        exit error ("u: $gid: invalid id field, gid or groupname expected") if ($gid eq "-" or $gid eq "");
                                        exit error ("u: $gid: groupname not found in system") unless (match_name("$root/etc/group", "$gid"));
                                    }
                                    #UID
                                    if ($id eq "-") { $id="";
                                    }
                                    else {
                                        exit error ("u: $id: numeric (u)id expected") unless (is_number($id));
                                        if (match_uid("$root/etc/passwd", "$id")) {$id="";} #fallback to auto allocation for UID
                                    }
                                    if ($id eq "") { #auto alloc of (u)id; at this point gid is expected to be valid
                                        $lrange="-g $gid"; #useradd: -g gid can be number or groupname
                                    }
                                    else {
                                        $lrange="-u $id -g $gid";
                                    }
                    }
                elsif (is_path($fields[2])) { #/path/to/file or dir
                    $id = (stat("$root/$fields[2]"))[4]; #NOTE: [4] = uid, [5]= gid, use gid for g lines
                    $gid = (stat("$root/$fields[2]"))[5];
                    if ( not defined $id and not defined $gid ) {#A #stat failed, path not found, fallback to autoallocation
                        $id=""; $gid=""; $lrange="$range"; $usergroup = 0;
                        }
                    elsif ( defined $id and defined $gid ) {
                        #if GID does not exists, create it
                        if (match_uid("$root/etc/passwd", "$id")) {$upath="1";}
                        if (match_uid("$root/etc/group", "$gid")) {$gpath="1";
                            #we need this only for supplementary group in useradd; '-G $groupname'; see WARN below
                            #open (my $F, '<', "$root/etc/group") or die "failed to open $root/etc/group: $!";
                            #while (my $fline = <$F>) { #TODO: set groupname in match_uid with a third param
                            #    if ($fline =~ /^((.*)):(.*):\Q$gid\E:(.*)$/) {
                            #       $groupname=$1;
                            #       print "groupname is $groupname\n";#debug
                            #       last;
                            #   }
                            #}
                            #close $F;
                            }
                        if ($upath eq "1" and $gpath eq "1") { #D: UID and GID found in system: fallback to autoallocation
                            #WARNING should we use '-G $groupname'=supplgroup in useradd before setting gid=""? gid should be the groupname
                            # so that the user can access the path??  NOTE: systemd (v257) does not do that
                            $id=""; $gid=""; $lrange="$range"; $usergroup = 0;
                            }
                        elsif ($upath eq "0" and $gpath eq "0") { #E: UID and GID not found  in system
                            $usergroup = 1; $groupname=$name; $groupid="-g $gid"; $lrange="-u $id -g $gid"; #TODO RM groupid
                            }
                        elsif ($upath eq "1" and $gpath eq "0") { #B: UID found, GID not found
                            $usergroup = 1; $groupname=$name; $groupid="-g $gid"; #TODO RM groupid
                            if (match_uid("$root/etc/passwd", "$gid")) {#B2#UID=GID already taken, fallback to auto for uid
                                $lrange="-g $gid";
                                }
                            else { #B1#UID=GID free, use GID for both
                                $lrange="-u $gid -g $gid";
                                }
                            }
                        else {#$upath="0", $gpath="1"; #C: UID not found, GID found
                            #WARNING should we use '-G $groupname'=supplgroup in useradd before setting gid=""? gid should be the groupname
                            # so that the user can access the path??  NOTE: systemd (v257) does not do that
                            $gid=""; $usergroup = 0; $lrange="-u $id";
                            }
                        }
                    else {#error, problem with stat?, should not happen;
                        exit error ("u: path $id: unexpected behavior of stat()")
                        }
                    }
                else {exit error ("u: $fields[2]: invalid id field value ");}
                }
            else {#not defined = auto allocation
                $id=""; $gid=""; $lrange="$range"; $usergroup = 0;
                }
            if (is_reserved($id)) { exit error ("invalid id: $id: 65535  and 4294967295 are reserved")}
            if (is_reserved($gid)) { exit error ("invalid id: $gid: 65535  and 4294967295 are reserved")}
            #GECOS, home, shell
            if (not defined $fields[3]) {$gecos = "";}# default home and shell are fine
            elsif ($fields[3] eq "-" ) {$gecos = "";
                set_home_shell($fields[4], $fields[5]);
                }
            elsif ($fields[3] =~ /^"(.*)"$/) {
                $gecos = $fields[3]; #$gecos =$1;
                set_home_shell($fields[4], $fields[5]);
                }
            else {# handle U and GECOS field, string quoted with spaces
                my @gfields = split "\"" , $uline; $gecos ="\"$gfields[1]\""; #$gecos ="$gfields[1]";
                if (defined $gfields[2])  {
                    my @aftergf = split " ", $gfields[2];
                    set_home_shell($aftergf[0], $aftergf[1]);
                    }
                }
            if ($debug) {print "name: $name\n"; print "id: $id\n"; print "gid: $gid\n";}#debug
            if ($debug) {print "gecos: $gecos \n"; print"home: $home\n"; print"shell: $shell\n";}#debug
            if ($debug) {print "COMMAND(S) are: \n";}#debug
            if ($usergroup) { #create group (gid)
                if ($debug) {print "groupadd $prefix -r -g$gid $groupname\n";}
                system('groupadd', "$prefix", '-r', "-g$gid", "$groupname");
                if ($id ne "") { #uid + gid
                    if ($debug) {print "useradd $prefix -r -u$id -g$gid -d$home -s$shell  $name\n";}
                    system('useradd', "$prefix", '-r', "-u$id", "-g$gid" , "-d$home", "-s$shell",  "$name");
                    }
                else { #gid only
                    if ($debug) {print "useradd $prefix -r -g$gid -d$home -s$shell  $name\n";}
                    system('useradd', "$prefix", '-r', "-g$gid", "-d$home", "-s$shell",  "$name");
                    }
                }
            elsif ($id ne "") { #group=auto; uid
                if ($debug) {print "useradd $prefix -r -u$id -d$home -s$shell  $name\n";}
                system('useradd', "$prefix", '-r', "-u$id", "-d$home", "-s$shell",  "$name");
                }
            elsif ($rcounter) { #group=auto; range
                if ($debug) {print "useradd $prefix -r -KSYS_UID_MIN=$rmins[0] -KSYS_UID_MAX=$rmaxs[0] -d$home -s$shell  $name\n";}
                system('useradd', "$prefix", '-r', "-KSYS_UID_MIN=$rmins[0]", "-KSYS_UID_MAX=$rmaxs[0]", "-d$home", "-s$shell",  "$name");
                }
            else { #group=auto; no range, no uid
                if ($debug) {print "useradd $prefix -r -d$home -s$shell  $name\n";}
                system('useradd', "$prefix", '-r', "-d$home", "-s$shell",  "$name");
                }
            if ($gecos ne "") {
                if ($debug) {print "usermod -c$gecos $name\n";}
                system('usermod', "-c$gecos", "$name");
                }
            if ($fields[0] eq "u!") {
                if ($debug) {print "usermod $prefix -L $name\n";}
                system('usermod', "$prefix", '-L', "$name");
                }
            $name =""; $id=""; $gid=""; $gecos=""; $home='/'; $shell='/sbin/nologin';  $lrange=""; #@fields = ();
            $upath="0"; $gpath="0"; $usergroup = 0; $groupname="";
            if ($debug) {print "\n"; print "\n";}#debug
            }
        }
    if (@mlines) {
        for (@mlines) {
            my $mline = $_;  my @fields = split " " , $mline;
            if ($debug) {print "line is: $mline\n";}#debug
            $name = $fields[1]; if ($name eq "" || $name eq "-") { exit error ("m type: missing or invalid name field");}
            if ($fields[2] eq "" || $fields[2] eq "-") { exit error ("m: $fields[2]: missing or invalid id field");}
            elsif (is_number($fields[2]) ) {exit error ("m: $fields[2]: group name expected for id field");}
            elsif (is_path($fields[2]) ) {exit error ("m: $fields[2]: group name expected for id field");}
            elsif ($fields[2] =~ /^(.*):(.*)$/) {exit error ("m: $fields[2]: group name expected for id field");}
            else {$id=$fields[2]}
            if ($debug) {print "name: $name\n"; print "id: $id\n";}#debug
            if ($debug) {print "COMMANDS are: \n";}#debug
            unless (match_name("$root/etc/group", "$id")) { #create group if is not found
                if ($rcounter) {
                    if ($debug) {print "groupadd $prefix -r -KSYS_GID_MIN=$rmins[0] -KSYS_GID_MAX=$rmaxs[0] $id\n";}
                    system('groupadd', "$prefix", '-r', "-KSYS_GID_MIN=$rmins[0]", "-KSYS_GID_MAX=$rmaxs[0]", "$id");
                    }
                else {
                    if ($debug) {print "groupadd $prefix -r $id\n";}
                    system('groupadd', "$prefix", '-r', "$id");
                    }
                }
            unless (match_name("$root/etc/passwd", "$name")) {#now we are sure group exists, so create user if is not found
                if ($rcounter) {
                    if ($debug) {print "useradd $prefix -r -KSYS_UID_MIN=$rmins[0] -KSYS_UID_MAX=$rmaxs[0] -d$home -s$shell  $name\n";}
                    system('useradd', "$prefix", '-r', "-KSYS_UID_MIN=$rmins[0]", "-KSYS_UID_MAX=$rmaxs[0]", "-d$home", "-s$shell",  "$name");
                    }
                else {
                    if ($debug) {print "useradd $prefix -r -d$home -s$shell  $name\n";}
                    system('useradd', "$prefix", '-r', "-d$home", "-s$shell",  "$name");
                    }
                }
            if ($debug) {print "usermod $prefix -a -G$id  $name\n";}#NOTE: g lines adds as supplementary -g=primary// -G=supplementary
            system('usermod', "$prefix", '-a', "-G$id",  "$name");
            $name =""; $id=""; $gid="";#@fields = ();
            if ($debug) {print "\n"; print "\n";}#debug
            }
        }
    }
