#! /usr/bin/perl

# Unit tests for the UID/GID selection helpers in Debian::AdduserCommon:
# range_intersection, first_avail_uid, first_avail_gid, and
# first_avail_uid_gid.
#
# Unlike the other tests in this directory, this file does not spawn
# adduser/addgroup as external commands. It imports the real
# Debian::AdduserCommon module that adduser itself uses, and calls
# the functions directly, with getpwuid/getgrgid replaced by
# in-process fakes. This lets every branch (the uid/gid range
# intersection, the --ingroup/--gid preferred-uid logic, and the
# various pool_id/reserved-pool short circuits) be checked exactly,
# including cases -- like a non-overlapping uid/gid range, or an id
# free as a uid but taken as a gid -- that are awkward to set up
# reliably against the real system passwd/group databases.
#
# getpwuid/getgrgid are builtins, so a plain "sub getpwuid {...}" in
# this file's own package would not be seen by code running inside
# Debian::AdduserCommon's package: Perl resolves a bare builtin call
# in the package it is compiled in, not the caller's package. The
# override has to go through the CORE::GLOBAL:: slot, which applies
# to every package, including Debian::AdduserCommon.

use diagnostics;
use strict;
use warnings;
use Test::More;

our %fake_passwd; # uid => 1 if taken
our %fake_group;  # gid => 1 if taken

BEGIN {
    *CORE::GLOBAL::getpwuid = sub { my ($u) = @_; return $main::fake_passwd{$u} ? "x" : undef; };
    *CORE::GLOBAL::getgrgid = sub { my ($g) = @_; return $main::fake_group{$g}  ? "x" : undef; };
}

use Debian::AdduserCommon;

sub reset_fakes {
    %fake_passwd = ();
    %fake_group  = ();
}

# ============================================================
# range_intersection(min1, max1, min2, max2)
#
# Regression coverage for a bug where the intersection floor/ceiling
# were computed with a ($a, $b)[$a > $b] idiom that, for the common
# case of $a <= $b, indexes element 0 and so returns the *lower* of
# the two values where the higher one (the true intersection floor)
# was wanted -- and the mirror expression for the ceiling had the
# same problem in the other direction. This went unnoticed because
# the two ranges passed in are usually equal.
# ============================================================

subtest 'range_intersection' => sub {
    my ($f, $l) = range_intersection(100, 200, 100, 200);
    is($f, 100, 'equal ranges: floor');
    is($l, 200, 'equal ranges: ceiling');

    # uid range starts lower and ends lower than the gid range:
    # floor must be the higher start (the gid range's), ceiling
    # must be the lower end (the uid range's)
    ($f, $l) = range_intersection(100, 200, 150, 300);
    is($f, 150, 'asymmetric A: floor is the higher start');
    is($l, 200, 'asymmetric A: ceiling is the lower end');

    # mirror: gid range starts lower and ends lower
    ($f, $l) = range_intersection(150, 300, 100, 200);
    is($f, 150, 'asymmetric B: floor is the higher start');
    is($l, 200, 'asymmetric B: ceiling is the lower end');

    # non-overlapping ranges: floor ends up above ceiling, which
    # first_avail_uid_gid()'s scan loop naturally turns into "no
    # candidate found" without needing a special case
    ($f, $l) = range_intersection(100, 199, 300, 399);
    ok($f > $l, 'non-overlapping ranges: floor > ceiling');
};

# ============================================================
# first_avail_uid_gid(min, max, pool_id, reserved_uid_pool, reserved_gid_pool)
#
# Exercises the combined search over a range computed by
# range_intersection(), including the case that the original bug
# would get wrong: an id that is free as a uid but already taken as
# a gid (or vice versa) must be skipped entirely, not returned.
# ============================================================

subtest 'first_avail_uid_gid over a computed intersection' => sub {
    reset_fakes();
    %fake_group = (200 => 1, 201 => 1); # gid 200,201 taken
    my ($f, $l) = range_intersection(100, 999, 200, 999);
    is($f, 200, 'intersection floor');
    my $uid = first_avail_uid_gid($f, $l, undef);
    is($uid, 202, 'first id free in both, within the intersection');

    reset_fakes();
    %fake_group = (100 => 1); # 100 free as uid, taken as gid
    ($f, $l) = range_intersection(100, 999, 100, 999);
    $uid = first_avail_uid_gid($f, $l, undef);
    is($uid, 101, 'id free-as-uid-but-taken-as-gid is skipped');

    reset_fakes();
    ($f, $l) = range_intersection(100, 199, 300, 399);
    $uid = first_avail_uid_gid($f, $l, undef);
    is($uid, -1, 'non-overlapping ranges yield -1, not a false match');
};

subtest 'first_avail_uid_gid: reserved pools and pool_id' => sub {
    reset_fakes();
    my %reserved_uid_pool = (500 => 1);
    my $uid = first_avail_uid_gid(498, 502, undef, \%reserved_uid_pool, {});
    is($uid, 498, 'scan skips ids reserved by a pending UID_POOL entry');

    reset_fakes();
    %fake_group = (777 => 1); # pool_id target already taken as a gid
    $uid = first_avail_uid_gid(100, 999, 777);
    is($uid, -1, 'pool_id short-circuits and fails if taken as a gid');

    reset_fakes();
    $uid = first_avail_uid_gid(100, 999, 777);
    is($uid, 777, 'pool_id short-circuits and succeeds if free as a gid');
};

# ============================================================
# first_avail_uid(min, max, pool_id, preferred, reserved_uid_pool)
#
# preferred is the --ingroup/--gid feature: prefer a candidate id
# (the requested group's gid) if it is free as a uid, before
# falling back to the lowest free uid in range.
# ============================================================

subtest 'first_avail_uid: preferred-id parameter' => sub {
    reset_fakes();
    my $uid = first_avail_uid(100, 999, undef, 500);
    is($uid, 500, 'free preferred id is used directly, even though a lower free id exists');

    reset_fakes();
    %fake_passwd = (500 => 1); # preferred id already taken as a uid
    $uid = first_avail_uid(100, 999, undef, 500);
    is($uid, 100, 'taken preferred id falls back to the normal scan');

    reset_fakes();
    $uid = first_avail_uid(100, 199, undef, 9000);
    is($uid, 100, 'out-of-range preferred id is ignored, not just deprioritized');

    reset_fakes();
    my %reserved_uid_pool = (500 => 1); # preferred id reserved by a pending UID_POOL entry
    $uid = first_avail_uid(100, 999, undef, 500, \%reserved_uid_pool);
    is($uid, 100, 'reserved preferred id falls back to the normal scan');

    reset_fakes();
    $uid = first_avail_uid(100, 999, undef, undef);
    is($uid, 100, 'no preferred id given: behaves like the plain scan');
};

subtest 'first_avail_uid: pool_id takes priority over preferred' => sub {
    reset_fakes();
    %fake_passwd = (777 => 1); # pool_id target already taken -> pool path fails
    my $uid = first_avail_uid(100, 999, 777, 500);
    is($uid, -1, 'pool_id short-circuits and ignores preferred entirely');

    reset_fakes();
    $uid = first_avail_uid(100, 999, 777, 500);
    is($uid, 777, 'pool_id short-circuits and succeeds if free, ignoring preferred');
};

# ============================================================
# first_avail_gid(min, max, pool_id, reserved_gid_pool)
#
# Same shape as first_avail_uid, minus the preferred-id parameter
# (--ingroup/--gid only ever influences uid selection, never gid
# selection, since the gid in that case is already given).
# ============================================================

subtest 'first_avail_gid' => sub {
    reset_fakes();
    my $gid = first_avail_gid(100, 999, undef);
    is($gid, 100, 'plain scan returns the lowest free gid');

    reset_fakes();
    %fake_group = (100 => 1, 101 => 1);
    $gid = first_avail_gid(100, 999, undef);
    is($gid, 102, 'scan skips taken gids');

    reset_fakes();
    my %reserved_gid_pool = (100 => 1);
    $gid = first_avail_gid(100, 999, undef, \%reserved_gid_pool);
    is($gid, 101, 'scan skips ids reserved by a pending GID_POOL entry');

    reset_fakes();
    %fake_group = (777 => 1);
    $gid = first_avail_gid(100, 999, 777);
    is($gid, -1, 'pool_id short-circuits and fails if taken');

    reset_fakes();
    $gid = first_avail_gid(100, 999, 777);
    is($gid, 777, 'pool_id short-circuits and succeeds if free');
};

done_testing();

# vim: tabstop=4 shiftwidth=4 expandtab
