#!/usr/bin/perl -T
# Script to lock/unlock system files by making them immutable.
# Unlocking must be done as root from single user mode.
#
# This code was inspired by code by George Shaffer
# (http://geodsoft.com/howto/harden/OpenBSD/syslock.txt) but no
# longer bears much resemblance to it except in its function.
# ---
# Written 11 June 2004 by Jim Lippard (version 1.0).
# Modified 9 November 2006 by Jim Lippard to add do-not-recurse option (version 1.1).
#    File paths beginning with a + will not have contents of subdirectories locked or
#    unlocked.
# Modified 27 December 2011 by Jim Lippard to determine system or user
#    immutability from syslock.conf and add Linux support.
# Modified 1 October 2016 by Jim Lippard to add option - which will lock
#    contents of a directory but not the directory itself.
# Modified 2 October 2016 by Jim Lippard to add -s singleuser mode and
#    ! singleuser-unlocked option.
# Modified 8 October 2016 by Jim Lippard to add -c config option and
#    -g group option, and group tags for sets of paths.
# Modified 8 October 2016 by Jim Lippard to complain when bad args are
#    supplied.
# Modified 1 February 2023 by Jim Lippard to fix error in error message.
# Modified 12 February 2023 by Jim Lippard to warn about non-standard
#    characters in filenames in config and to test for existence of files
#    and issue warnings (without halting) if they don't exist rather than
#    trying to lock or unlock nonexistent files.
# Modified 20 May 2023 by Jim Lippard to only mount / rw if it's mounted
#    ro and to attempt to determine that on both BSD and Linux (previously
#    BSD location of mount command was assumed and the mount command was
#    always issued).
# Modified 30 August 2023 by Jim Lippard to add -v (verbose) flag.
#    Return / mount to read-only if we changed it.
# Modified 5 January 2024 by Jim Lippard to add -w (wait) flag to syslock.
#    If kernel reordering or library link randomization is in process,
#    wait for it to complete; for OpenBSD. Add pledge for OpenBSD,
#    but not bothering with unveil.
# Modified 18 January 2024 by Jim Lippard to remove extraneous \n from
#    $level and to no longer require that securelevel be < 1 for -s given
#    how rc.securelevel works. [Reverted latter with -f, must now use -swf.]
# Modified 13 July 2024 by Jim Lippard to add uchg group, which allows
#    locking and unlocking files with uchg when schg is the default. (If
#    using immutable-flag: uchg, it's a no-op.)
# Modified 14 July 2024 by Jim Lippard to allow -g group:uchg, to allow
#    locking and unlocking files that are in group and are also in uchg
#    group.
# Modified 20 July 2024 by Jim Lippard to add schg group and allow
#    -g group:schg to allow locking and unlocking files that are in
#    group and also in schg group. (If using immutable-flag: schg,
#    it's a no-op.) If sysunlock is used without args and system is not
#    in single-user mode, files in schg group will not be unlocked.
#    (A warning will be displayed if -v is used, otherwise this will
#    occur silently.)
#    Added -f (force) option to force attempt to lock schg files even
#    if securelevel > 0 (and changed behavior back to not do so by
#    default).
# Modified 29 August 2024 by Jim Lippard to add error checking on pledge.
# Modified 30 August 2024 by Jim Lippard to add -a (audit) option, which
#    makes no changes but simply reports divergences from the configuration
#    (or parts thereof based on -g options). Ignores system securelevel
#    limitations. With syslock, tells what is still unlocked; with sysunlock,
#    tells what is still locked.
# Modified 31 August 2024 by Jim Lippard to fix some dumb Linux bugs,
#    including failing to use lsattr -d to get directory immutable flags
#    instead of listing of flags on directory contents.
# Modified 28 November 2024 by Jim Lippard to reverse order of lib randomization
#    and kernel reordering checks.
# Modified 14 July 2025 by Jim Lippard to reject config file paths containing
#    shell metacharacters and to execute system calls without shell except
#    when needed (ideally would do my its own recursion), both related to
#    potential unsanitized input in config file that could be used for
#    command injection--but the config should only be writable by root.
# Modified 14 July 2025 by Jim Lippard with suggestions from ChatGPT 4o to
#    tighten up security slightly more.
# Modified 2 August 2025 by Jim Lippard to make it work more similarly
#    for immutable-flag: schg regarding uchg groups as it works for
#    schg groups with immutable-flag: uchg -- when securelevel > 0,
#    implicitly just handle uchg groups, with requisite warnings if -v
#    is used and locking the schg files only with -f.
# Modified 3 August 2025 by Jim Lippard to make uchg the default for
#    macOS, since schg is now equivalent to uchg and there is no single-user
#    mode on Apple Silicon. Apple uses an alternative "restricted" flag
#    for Apple binaries.
# Modified 14 September 2025 by Jim Lippard to ignore special character
#    files and files mounted on non-standard filesystems in addition to
#    symlinks when checking for flags on files on Linux.
# Modified 4 January 2026 by Jim Lippard to remove & on subroutine calls.
# Modified 31 January 2026 by Jim Lippard to add taint checking, better
#    config file checks for path validation and config file permissions,
#    use unveil on OpenBSD to prevent file executions, eliminate use of
#    glob and backticks.
# Modified 27 March 2026 by Claude at the direction of Jim Lippard to add
#    append-only flag support:
#    BSD uappnd/nouappnd group tag and Linux +a/-a group tag. These behave
#    like uchg groups but are never blocked by securelevel. Processed on all
#    invocations including no-arg syslock/sysunlock. To narrow to only the
#    append-only subset of a group, use "-g <group>:uappnd" (BSD) or
#    "-g <group>:+a" (Linux), or "-g uappnd" / "-g +a" for all append-only
#    files. The "uappnd"/"+a" tag may not appear in the immutable-flag:
#    field. A group definition may not combine uappnd with uchg or schg
#    (they are mutually exclusive per group line; use two group lines with
#    a shared group name to apply different flags to different files within
#    the same logical group).
#    Also added glob support in config file paths: glob characters (*, ?, [])
#    are allowed in the filename component of a path (the final segment after
#    the last /). The directory prefix must be a literal path. A bare /*
#    filename is not allowed (use directory syntax instead). Globs are
#    expanded at config-parse time; non-matching globs produce a warning.
# Modified 29 March 2026 by Jim Lippard to fix Linux root check to use
#    effective UID ($>) instead of $ENV{USER}, which is not set in all cron
#    environments.
# Modified 29 March 2026 by Claude at the direction of Jim Lippard:
#    Added non-root support on BSD/macOS: non-root users may run syslock/
#    sysunlock to manage uchg and uappnd flags on files they own. Non-root
#    users default to ~/.syslock.conf (or specify -c), may not use schg or
#    immutable-flag: schg, and may not use -s/-w/-f options. The securelevel
#    check is skipped for non-root (not applicable to uchg/uappnd). A warning
#    is issued at config-parse time for any listed file not owned by the
#    invoking user. Linux chattr remains root-only.
#    Added sappnd (BSD system append-only flag) group support. sappnd mirrors
#    schg behavior with respect to securelevel: both setting and removing
#    sappnd require securelevel < 1, or -f to force setting. This ensures
#    that a group can be symmetrically locked and unlocked at a given
#    securelevel -- if sappnd cannot be removed at the current securelevel,
#    it will not be set either (unless -f is used). sappnd must be explicitly
#    specified in a group tag (never implied by the global immutable-flag),
#    may not appear in the immutable-flag: field, may not be combined with
#    other flag types in the same group line, and is root-only. Together with
#    schg, uchg, and uappnd, this covers all BSD locking-related chflags.
# Modified 8 April 2026 by Jim Lippard to use "use warnings" instead of -w.
#    Added -q (quiet) option for use with -a (audit): suppresses all per-file
#    output and communicates result via exit code only. Exit 0 means the
#    filesystem matches the expected state; exit 1 means at least one
#    divergence exists. Useful for scripted checks, e.g.:
#      sysunlock -a -q -g local && proceed_with_install
# Modified 13 April 2026 by Claude at the direction of Jim Lippard to
#    make sysunlock -a ignore flags that are not locking-related flags
#    and make syslock -a note discrepancies where other locking-related
#    flags are present (in both BSD and Linux cases).
# Modified 16 April 2026 by Jim Lippard to warn if config file is world
#    readable (and OpenBSD package now sets it to 0600).
# Modified 19 April 2026 by Jim Lippard to remove forcing of uchg on
#    macOS. schg works and makes sense and cannot be removed from a
#    file except by root, but macOS is usually ALWAYS at securelevel=0.
#    This can be problematic if an admin changes securelevel to >0, as
#    there is no single user mode to allow disabling schg flags; if an
#    admin sets schg on /etc/rc.securelevel and uses that to increase
#    securelevel, it will require either modifying other scripts that
#    run earlier to take schg off of rc.securelevel or booting into
#    Safe Mode.
# Modified 3 May 2026 by Claude at the direction of Jim Lippard to fix
#    -a (audit) incorrectly skipping schg/sappnd files at securelevel > 0
#    (the per-file next was not guarded by !$audit_flag, causing audit to
#    silently skip all schg/sappnd files and falsely report clean state).
#    Added -o (operational) option for use with -a (audit): restricts the
#    audit to only the files that the corresponding lock/unlock operation
#    would actually touch at the current securelevel. Without -o, -a checks
#    all files in the group regardless of securelevel constraints (full
#    diagnostic scope). With -o, schg/sappnd files are skipped at
#    securelevel > 0 just as they would be in a normal lock/unlock run,
#    giving an accurate "ready for this operation" answer. Useful for
#    scripted pre-flight checks, e.g.:
#      sysunlock -a -q -o -g local && proceed_with_install
# Modified 10 May 2026 by Claude at the direction of Jim Lippard to fix
#    tainted readdir()/glob() results being passed to system() under -T
#    taint mode (affected _recurse_lock_or_unlock, lock/unlock dont_recurse
#    and dont_lock_top_level_dir blocks, and _audit_file). All readdir/glob
#    results are now untainted via a safe character regex before use.
#    Added = path prefix: "lock this directory and its direct file contents
#    only, skipping subdirectories entirely." Useful when subdirectory
#    contents are managed by a separate group (e.g. =/boot/grub locks
#    /boot/grub itself and files directly within it, but not x86_64-efi/,
#    fonts/, locale/ etc.).
#    Added _check_ancestor_conflicts(): after config parsing, on BSD when
#    more than one flag type is in use, warns if any config path would be
#    processed by an ancestor directory entry using a different flag type.
#    This detects cases where two groups could set conflicting flags on the
#    same file (e.g. schg via a parent directory and uchg via an explicit
#    file entry). Symlinks are skipped with a warning. The prefix flags
#    (+, -, =) are taken into account to determine whether the ancestor
#    would actually process the descendant path.
# Modified 10 May 2026 by Jim Lippard to modify unveil permissions on
#    commands from x to rx since the r is required for -a (audit).
#    Modified the permissions on / to be rx for audit or rw for making
#    changes.
# Modified 16 May 2026 by Jim Lippard to do config file permissions tests
#    after file open (avoid TOCTOU), use $fh format opens instead of
#    bareword filehandles, ignore links and character files for any OS,
#    not just Linux, be more restrictive on unveiling, and switch to
#    block form greps; all but last suggested by Gemini security assessment.
# Modified 17 May 2026 by Jim Lippard to fix operator precedence error
#    introduced in _desired_flag_mismatch by last prior change. Fix bug
#    in -a -o processing for schg files on a schg-default system. Allow
#    sysunlock -g <group>:schg -a -q to exit with 0 if there are no
#    schg members of <group>, to facilitate use by install.pl.
# Modified 18 May 2026 by Jim Lippard to fix another bug introduced by
#    the block grep switch, removing a single backslash from in front
#    of "+a" which was formerly required for regexp escaping.
# Modified 19 May 2026 by Jim Lippard for fast exit with -a -q (as
#    soon as the first mismatch is found).
#
# Note for OpenBSD:
#    File paths beginning with a ! will be locked or unlocked when -s
#    is used (as well as when it is not). File paths without a ! will
#    not be touched when -s is used. This allows you to put syslock -s
#    in rc.securelevel and do sysunlock -s after rc.shutdown, so that
#    files with ! will be unlocked when in single user mode. This was
#    added to allow shared object library reordering, which occurs on
#    boot prior to securelevel change. This means all libraries end up
#    unlocked in single user mode, unless all other libraries are
#    listed individually, since there's not currently a way to make an
#    exception within a directory that is otherwise being locked.

### Required packages.

use strict;
use warnings;
use Getopt::Std;
use if $^O eq 'openbsd', 'OpenBSD::Pledge';
use if $^O eq 'openbsd', 'OpenBSD::Unveil';

### Sanitize environment.
BEGIN {
    $ENV{PATH} = '/usr/bin:/bin';
    delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};
}

### Global constants.

my $DEFAULT_CONFIG_FILE = '/etc/syslock.conf';

my $VERSION = '1.18a of 20 May 2026';

my $LOCK = 0;
my $UNLOCK = 1;

my $BSD_SYS_IMMUTABLE_FLAG_ON = 'schg';
my $BSD_USER_IMMUTABLE_FLAG_ON = 'uchg';
my $BSD_SYS_APPEND_FLAG_ON = 'sappnd';
my $BSD_USER_APPEND_FLAG_ON = 'uappnd';
my $LINUX_IMMUTABLE_FLAG_ON = '+i';
my $LINUX_IMMUTABLE_FLAG_OFF = '-i';
my $LINUX_APPEND_FLAG_ON = '+a';
my $LINUX_APPEND_FLAG_OFF = '-a';

my $BSD_MOUNT_COMMAND = '/sbin/mount';
my $LINUX_MOUNT_COMMAND = '/usr/bin/mount';
my @BSD_MOUNT_ARGS = ('-uw', '/');
my @BSD_MOUNT_RO_ARGS = ('-u', '-o', 'ro', '/');
my @LINUX_MOUNT_ARGS = ('-o', 'remount,rw');
my @LINUX_MOUNT_RO_ARGS = ('-o', 'remount,ro');
my $BSD_LOCK_COMMAND = '/usr/bin/chflags';
my $LINUX_LOCK_COMMAND = '/usr/bin/chattr';
my $RECURSE_FLAG = '-R';
my $SYSCTL = '/sbin/sysctl';
$SYSCTL = '/usr/sbin/sysctl' if ($^O eq 'darwin');
my $BSD_SECURELEVEL_COMMAND = "$SYSCTL kern.securelevel";

my $ECHO = '/bin/echo';
my $PS = '/bin/ps';
my $REORDER_KERNEL = '/usr/libexec/reorder_kernel';
my $RELINK_DIR = '/usr/share/relink';
my $REBUILD_PREFIX = '_rebuild';
my $FIVE_MINUTES = 300;

my $LOCK_OPTIONS = 'asdfc:g:vwqoV';
my $UNLOCK_OPTIONS = 'asdc:g:vqoV';

use constant {
    PERMS_WORLD_WRITABLE => 0002,
    PERMS_WORLD_READABLE => 0004,
    PERMS_GROUP_WRITABLE => 0020,
    UID_ROOT => 0,
};

### Global variables.

my ($mode, $level, @files, $file, %dont_recurse, %dont_lock_top_level_dir, %files_only, %singleuser_unlocked, %group_tags, @all_group_tags, @new_group_tags, $group_tag,
    @uchg_group_tags, @schg_group_tags, @uappnd_group_tags, @sappnd_group_tags);

my ($config_file, $bsd, $linux, @lock_command, $lock_flag, $unlock_flag,
    $singleuser_flag,
    $uchg_group_flag, # uchg group exists
    $uchg_group_in_use_flag, # uchg group is used in the section of config or current processing
    $uchg_group_specified_flag, # -g uchg or -g group:uchg
    $schg_group_flag, # schg group exists
    $schg_group_in_use_flag, # schg group is used in the section of config or current processing
    $schg_group_specified_flag, # -g schg or -g group:schg
    $uappnd_group_flag, # uappnd/+a group exists (BSD: uappnd, Linux: +a)
    $uappnd_group_in_use_flag, # uappnd/+a group is used in the section of config or current processing
    $uappnd_group_specified_flag, # -g uappnd or -g group:uappnd (BSD), -g +a or -g group:+a (Linux)
    $sappnd_group_flag, # sappnd group exists (BSD only, root only)
    $sappnd_group_in_use_flag, # sappnd group is used in the section of config or current processing
    $sappnd_group_specified_flag, # -g sappnd or -g group:sappnd
    $nonroot_flag, # running as non-root on BSD; restricts to uchg/uappnd only
    $debug_flag,
    $specified_group_tag, %opts, $options,
    $current_group_tags, @file_group_tags, $path, $verbose_flag,
    $displayed_verbose_group_msg_flag,
    $root_was_ro, $wait_flag, $waiting, $waiting_on_kernel,
    $waiting_on_libs, $start_time, $reason,
    $force_flag, $audit_flag, $quiet_flag, $operational_flag);
my ($temp_lock_flag, $temp_unlock_flag);

### Main program.

if ($0 =~ /syslock$/) {
    $mode = $LOCK;
    $options = $LOCK_OPTIONS;
}
elsif ($0 =~ /sysunlock$/) {
    $mode = $UNLOCK;
    $options = $UNLOCK_OPTIONS;
}
else {
    die "syslock/sysunlock: I've been invoked under someone else's name: $0\n";
}

$singleuser_flag = 0;
$debug_flag = 0;
$specified_group_tag = 0;
$uchg_group_flag = 0;
$schg_group_flag = 0;
$uappnd_group_flag = 0;
$sappnd_group_flag = 0;
$verbose_flag = 0;
$wait_flag = 0;
$force_flag = 0;
$audit_flag = 0;
$quiet_flag = 0;
$operational_flag = 0;
$nonroot_flag = 0;

# Get options.
getopts ($options, \%opts) || exit;

if ($#ARGV != -1) {
    if ($mode == $LOCK) {
	die "Usage: $0 [-s|-d|-v|-w|-f|-a|-V|-c config_file|-g group-tag]\n";
    }
    else {
	die "Usage: $0 [-s|-d|-v|-a|-V|-c config_file|-g group-tag]\n";
    }
}

$specified_group_tag = $opts{'g'};
$uchg_group_specified_flag = 1 if (defined ($specified_group_tag) && $specified_group_tag =~ /$BSD_USER_IMMUTABLE_FLAG_ON$/ && $specified_group_tag !~ /$BSD_USER_APPEND_FLAG_ON$/);
$schg_group_specified_flag = 1 if (defined ($specified_group_tag) && $specified_group_tag =~ /$BSD_SYS_IMMUTABLE_FLAG_ON$/ && $specified_group_tag !~ /$BSD_SYS_APPEND_FLAG_ON$/);
$sappnd_group_specified_flag = 1 if (defined ($specified_group_tag) && $specified_group_tag =~ /(?:^|:)$BSD_SYS_APPEND_FLAG_ON$/);
# uappnd (BSD) or +a (Linux) as suffix or standalone.
if ($bsd) {
    $uappnd_group_specified_flag = 1 if (defined ($specified_group_tag) && $specified_group_tag =~ /(?:^|:)$BSD_USER_APPEND_FLAG_ON$/);
}
else {
    # On Linux, +a can only appear standalone or as :+a suffix -- handle after OS detection.
}
$singleuser_flag = $opts{'s'};
$debug_flag      = $opts{'d'};
$verbose_flag    = $opts{'v'};
$wait_flag       = $opts{'w'};
$force_flag      = $opts{'f'};
$audit_flag      = $opts{'a'};
$quiet_flag      = $opts{'q'};
$operational_flag = $opts{'o'};

die "Cannot use -a (audit) with -f (force).\n" if ($audit_flag && $force_flag);
die "Cannot use -a (audit) with -w (wait).\n" if ($audit_flag && $wait_flag);
die "Cannot use -a (audit) with -v (verbose).\n" if ($audit_flag && $verbose_flag);
die "Cannot use -q (quiet) without -a (audit).\n" if ($quiet_flag && !$audit_flag);
die "Cannot use -o (operational) without -a (audit).\n" if ($operational_flag && !$audit_flag);
die "-w option not supported by your operating system.\n" if ($wait_flag && $^O ne 'openbsd');

# Non-root detection: only meaningful on BSD. Linux non-root is handled
# later in the OS detection block (chattr requires root on Linux).
# Must be done before config file path is computed and before option
# validation.
if ($> != 0 && $^O ne 'linux') {
    $nonroot_flag = 1;
    die "The -s (single-user) option is not available for non-root users.\n" if ($singleuser_flag);
    die "The -w (wait) option is not available for non-root users.\n"        if ($wait_flag);
    die "The -f (force) option is not available for non-root users.\n"       if ($force_flag);
    die "The -g sappnd option is not available for non-root users.\n"        if ($sappnd_group_specified_flag);
}

$config_file = $opts{'c'} || do {
    if ($nonroot_flag) {
	# Non-root BSD user: use ~/.syslock.conf unless -c was given.
	my $home = $ENV{'HOME'};
        die "HOME environment variable is not set.\n" unless defined $home;
        die "HOME environment variable must be an absolute path.\n" unless $home =~ m{^/};
        die "HOME environment variable contains path traversal.\n"  if $home =~ /\.\./;
        # Untaint HOME.
        ($home) = ($home =~ m{^([-\w./]+)$})
            or die "HOME environment variable contains unsafe characters.\n";
        "$home/.syslock.conf";
    }
    else {
        $DEFAULT_CONFIG_FILE;
    }
};

die "Cannot use -V (version) with other options.\n" if ($opts{'V'} &&
							($singleuser_flag ||
							 $debug_flag ||
							 $verbose_flag ||
							 $wait_flag ||
							 $force_flag ||
							 $audit_flag ||
							 $quiet_flag ||
							 $operational_flag ||
							 $specified_group_tag ||
							 $opts{'c'}));

# -V (version).
if ($opts{'V'}) {
    print "$0 $VERSION\n";
    exit;
}

# Pledge and unveil.
if ($^O eq 'openbsd') {
    # Most commands need only r for -a audit and x otherwise.
    my $cmd_perms = $audit_flag ? 'r' : 'x'; # $BSD_MOUNT_COMMAND and $ECHO
    # Some need to need rx for -a audit and x otherwise.
    my $exec_perms = $audit_flag ? 'rx' : 'x'; # $LS, $SYSCTL (executed during -a)
    unveil ($config_file, 'r') || die "Cannot unveil config file. $!\n";
    # $BSD_LOCK_COMMAND always needs r so we can see if it exists.
    unveil ($BSD_LOCK_COMMAND, 'rx') || die "Cannot unveil BSD lock command (chflags). $!\n";
    my $LS = '/bin/ls';
    unveil ($LS, $exec_perms) || die "Cannot unveil ls. $!\n";
    # $BSD_MOUNT_COMMAND needs r for being audited and x for being run, which it isn't during audit.
    unveil ($BSD_MOUNT_COMMAND, $cmd_perms) || die "Cannot unveil BSD mount command. $!\n";
    unveil ($SYSCTL, $exec_perms) || die "Cannot unveil sysctl. $!\n";
    if ($debug_flag) {
	unveil ($ECHO, $cmd_perms) || die "Cannot unveil echo. $!\n";
    }

    if ($wait_flag) { # mutually inconsistent with -a, so no read on commands, just dir
	unveil ($PS, 'x') || die "Cannot unveil ps. $!\n";
	unveil ($REORDER_KERNEL, 'x') || die "Cannot unveil reorder_kernel. $!\n";
	unveil ($RELINK_DIR, 'r') || die "Cannot unveil relink dir. $!\n";
    }

    # All that's needed for audit. If making changes we'll do more unveiling after
    # getting the files from the config file.
    unveil ('/', 'rx');
    if ($audit_flag) {
	unveil ();
    }
    
    # stdio included automatically.
    my @promises = ('rpath', 'wpath', 'fattr', 'exec', 'proc');
    push (@promises, 'unveil') unless $audit_flag;
    pledge (@promises) || die "Cannot pledge promises. $!\n";
}

print "singleuser_flag = 1\n" if ($debug_flag && $singleuser_flag);

$bsd = $linux = 0;

$current_group_tags = 0;

$root_was_ro = 0;

# BSD or Linux?
if (-e $BSD_LOCK_COMMAND) {
    $bsd = 1;
    @lock_command = ($BSD_LOCK_COMMAND);
    # Non-root BSD users are restricted to uchg/uappnd regardless of
    # immutable-flag: setting in config; the config parse enforces this too.
    if ($nonroot_flag) {
        $lock_flag = $BSD_USER_IMMUTABLE_FLAG_ON;
        $unlock_flag = "no$lock_flag";
    }
    # Otherwise BSD default is system immutability, but this is changeable
    # in config file.
    else {
	$lock_flag = $BSD_SYS_IMMUTABLE_FLAG_ON;
	$unlock_flag = "no$lock_flag";
    }
}
elsif (-e $LINUX_LOCK_COMMAND) {
    if ($> != 0) { # use effective UID instead of $ENV{'USER'} which is unlikely to be set on Linux in cron env.
	die "Must be run as root.\n";
    }
    $linux = 1;
    @lock_command = ($LINUX_LOCK_COMMAND, '-f');
    $lock_flag = $LINUX_IMMUTABLE_FLAG_ON;
    $unlock_flag = $LINUX_IMMUTABLE_FLAG_OFF;
    # Detect +a group specification on Linux now that we know it's Linux.
    $uappnd_group_specified_flag = 1 if (defined ($specified_group_tag) && $specified_group_tag =~ /(?:^|:)\+a$/);
}
else {
    die "Immutability flags do not appear to be supported by your system.\n";
}

if ($debug_flag) {
    unshift (@lock_command, $ECHO);
}

print "DEBUG: opening config file\n" if ($debug_flag);
open (my $config_fh, '<', $config_file) || die "Cannot open config file. $config_file\n";

# Verify config file is root-owned and not world-writable after open
# to avoid TOCTOU race.
my @stat = stat ($config_fh);
if (!@stat) {
    close ($config_fh);
    die "Cannot stat config file $config_file: $!\n";
}

my ($file_mode, $uid) = @stat[2, 4];

if ($uid != UID_ROOT && $uid != $>) {
    close ($config_fh);
    die "Config file $config_file must be owned by root or the invoking user (currently uid $uid)\n";
}

if ($file_mode & PERMS_WORLD_WRITABLE) {  # World-writable
    close ($config_fh);
    die "Config file $config_file must not be world-writable\n";
}

if ($file_mode & PERMS_WORLD_READABLE) {  # World-readable
    warn "Config file $config_file is world-readable; consider chmod 0600.\n";
}

if ($file_mode & PERMS_GROUP_WRITABLE) {  # Group-writable
    warn "Warning: Config file $config_file is group-writable\n";
}

while (<$config_fh>) {
    chomp;
    if (/^\s*$|^\s*#/) { # Comment or blank space.
    }
    elsif (/^immutable-flag:\s+(\w+)$/) {
	if (($1 eq $BSD_SYS_IMMUTABLE_FLAG_ON) ||
		     ($1 eq $BSD_USER_IMMUTABLE_FLAG_ON)) {
	    if ($linux) {
		die "Config file specifies BSD lock flag type but this appears to be a Linux system.\n";
	    }
	    if ($nonroot_flag && $1 eq $BSD_SYS_IMMUTABLE_FLAG_ON) {
		die "Config file specifies schg but non-root users may only use uchg.\n";
	    }
	    $lock_flag = $1;
	    $unlock_flag = "no$1";
	}
	elsif ($1 eq $LINUX_IMMUTABLE_FLAG_ON) {
	    if ($bsd) {
		die "Config file specifies Linux lock flag type but this appears to be a BSD system.\n";
	    }
	}
	elsif ($1 eq $BSD_USER_APPEND_FLAG_ON) {
	    die "Config file: \"$BSD_USER_APPEND_FLAG_ON\" may not be used in immutable-flag: field; use it as a group tag instead.\n";
	}
	elsif ($1 eq $BSD_SYS_APPEND_FLAG_ON) {
	    die "Config file: \"$BSD_SYS_APPEND_FLAG_ON\" may not be used in immutable-flag: field; use it as a group tag instead.\n";
	}
	else {
	    die "Config file specifies unknown lock flag type \"$1\".\n";
	}
    }
    elsif (/^group:\s+([\w\s\-\+]+)/) {
	$current_group_tags = $1;
	print "DEBUG: current_group_tags = $current_group_tags\n" if ($debug_flag);
	@new_group_tags = split (/\s+/, $current_group_tags);
	if ($bsd && grep { $_ eq $BSD_USER_IMMUTABLE_FLAG_ON } @new_group_tags) {
	    $uchg_group_flag = 1;
	    $uchg_group_in_use_flag = 1;
	}
	else {
	    $uchg_group_in_use_flag = 0;
	}
	if ($bsd && grep { $_ eq $BSD_SYS_IMMUTABLE_FLAG_ON } @new_group_tags) {
	    die "Cannot use both $BSD_USER_IMMUTABLE_FLAG_ON and $BSD_SYS_IMMUTABLE_FLAG_ON groups in combination. $_\n" if ($uchg_group_in_use_flag);
	    die "Non-root users may not use schg groups. $_\n" if ($nonroot_flag);

	    $schg_group_flag = 1;
	    $schg_group_in_use_flag = 1;
	}
	else {
	    $schg_group_in_use_flag = 0;
	}
	# Detect BSD uappnd group tag.
	if ($bsd && grep { $_ eq $BSD_USER_APPEND_FLAG_ON } @new_group_tags) {
	    die "Cannot combine $BSD_USER_APPEND_FLAG_ON with $BSD_USER_IMMUTABLE_FLAG_ON or $BSD_SYS_IMMUTABLE_FLAG_ON in the same group. $_\n"
		if ($uchg_group_in_use_flag || $schg_group_in_use_flag);
	    $uappnd_group_flag = 1;
	    $uappnd_group_in_use_flag = 1;
	}
	# Detect BSD sappnd group tag.
	elsif ($bsd && grep { $_ eq $BSD_SYS_APPEND_FLAG_ON } @new_group_tags) {
	    die "Cannot combine $BSD_SYS_APPEND_FLAG_ON with $BSD_USER_IMMUTABLE_FLAG_ON or $BSD_SYS_IMMUTABLE_FLAG_ON in the same group. $_\n"
		if ($uchg_group_in_use_flag || $schg_group_in_use_flag);
	    die "Cannot combine $BSD_SYS_APPEND_FLAG_ON with $BSD_USER_APPEND_FLAG_ON in the same group. $_\n"
		if ($uappnd_group_in_use_flag);
	    die "Non-root users may not use sappnd groups. $_\n" if ($nonroot_flag);
	    $sappnd_group_flag = 1;
	    $sappnd_group_in_use_flag = 1;
	}
	# Detect Linux +a group tag.
	elsif ($linux && grep { $_ eq '+a' } @new_group_tags) {
	    $uappnd_group_flag = 1;
	    $uappnd_group_in_use_flag = 1;
	}
	else {
	    $uappnd_group_in_use_flag = 0;
	    $sappnd_group_in_use_flag = 0;
	}
	# Prepend implicit tag for immutable flag (only when not a uappnd/+a/sappnd group).
	if ($bsd && !$schg_group_in_use_flag && !$uchg_group_in_use_flag && !$uappnd_group_in_use_flag && !$sappnd_group_in_use_flag) {
	    $current_group_tags = $lock_flag . ' ' . $current_group_tags;
	    $schg_group_in_use_flag = 1 if ($lock_flag eq $BSD_SYS_IMMUTABLE_FLAG_ON);
	    $uchg_group_in_use_flag = 1 if ($lock_flag eq $BSD_USER_IMMUTABLE_FLAG_ON);
	}
	foreach $group_tag (@new_group_tags) {
	    push (@uchg_group_tags, $group_tag) if ($bsd &&
						    $uchg_group_in_use_flag &&
						    $group_tag ne $BSD_USER_IMMUTABLE_FLAG_ON);
	    push (@schg_group_tags, $group_tag) if ($bsd &&
						    $schg_group_in_use_flag &&
						    $group_tag ne $BSD_SYS_IMMUTABLE_FLAG_ON);
	    push (@uappnd_group_tags, $group_tag) if (($bsd &&
						       $uappnd_group_in_use_flag &&
						       $group_tag ne $BSD_USER_APPEND_FLAG_ON) ||
						      ($linux &&
						       $uappnd_group_in_use_flag &&
						       $group_tag ne '+a'));
	    push (@sappnd_group_tags, $group_tag) if ($bsd &&
						      $sappnd_group_in_use_flag &&
						      $group_tag ne $BSD_SYS_APPEND_FLAG_ON);
	    if (!grep { $_ eq $group_tag } @all_group_tags) {
		push (@all_group_tags, $group_tag);
	    }
	}
    }
    elsif (/^group:/) {
	die "Invalid characters in \"group:\" tag. $_\n";
    }
    elsif (/^([\-\+!=])(.*)/) {
	$options = $1;
	$path = $2;
	if ($path =~ /[*?\[]/) {
	    # Glob pattern -- expand and register each result.
	    my @expanded = _expand_glob ($path, $options);
	    for my $expanded_path (@expanded) {
		$dont_lock_top_level_dir{$expanded_path} = 1 if ($options =~ /-/);
		$dont_recurse{$expanded_path} = 1           if ($options =~ /\+/);
		$files_only{$expanded_path} = 1             if ($options eq '=');
		$singleuser_unlocked{$expanded_path} = 1    if ($options =~ /!/);
		push (@files, $expanded_path);
		$group_tags{$expanded_path} = $current_group_tags if ($current_group_tags);
	    }
	}
	else {
	    $path = _file_warnings ($path, $options); # get untainted path back
	    
	    if ($options =~ /-/) {
		$dont_lock_top_level_dir{$path} = 1;
		print "DEBUG: dont_lock_top_level_dir: $path\n" if ($debug_flag);
	    }
	    if ($options =~ /\+/) {
		$dont_recurse{$path} = 1;
		print "DEBUG: dont_recurse: $path\n" if ($debug_flag);
	    }
	    if ($options eq '=') {
		$files_only{$path} = 1;
		print "DEBUG: files_only: $path\n" if ($debug_flag);
	    }
	    if ($options =~ /!/) {
		$singleuser_unlocked{$path} = 1;
		print "DEBUG: singleuser_unlocked: $path\n" if ($debug_flag);
	    }
	    push (@files, $path);
	    $group_tags{$path} = $current_group_tags if ($current_group_tags);
	}
    }
    else {
	if ($_ =~ /[*?\[]/) {
	    # Glob pattern -- expand and register each result.
	    my @expanded = _expand_glob ($_, '');
	    for my $expanded_path (@expanded) {
		push (@files, $expanded_path);
		$group_tags{$expanded_path} = $current_group_tags if ($current_group_tags);
	    }
	}
	else {
	    $path = _file_warnings ($_);
	    push (@files, $path);
	    $group_tags{$path} = $current_group_tags if ($current_group_tags);
	}
    }
}
close ($config_fh);
print "DEBUG: closing config file\n" if ($debug_flag);

# Reset per-group-line in-use flags that were left set by config parsing.
# They are re-driven file-by-file in the main loop below.
$uchg_group_in_use_flag  = 0;
$schg_group_in_use_flag  = 0;
$uappnd_group_in_use_flag = 0;
$sappnd_group_in_use_flag = 0;

# Save the global immutability lock/unlock flags as established by OS
# detection and the immutable-flag: config directive. Used by the uappnd
# restore branch to reset $lock_flag when no uchg/schg block fired.
my $global_lock_flag   = $lock_flag;
my $global_unlock_flag = $unlock_flag;

# Check for ancestor/descendant conflicts where the same file path could
# be processed by two different groups using different flag types.
# Only meaningful on BSD where multiple flag types exist.
{
    my %flag_types_in_use;
    $flag_types_in_use{$lock_flag} = 1;
    $flag_types_in_use{$BSD_SYS_IMMUTABLE_FLAG_ON}  = 1 if $schg_group_flag;
    $flag_types_in_use{$BSD_USER_IMMUTABLE_FLAG_ON} = 1 if $uchg_group_flag;
    $flag_types_in_use{$BSD_SYS_APPEND_FLAG_ON}     = 1 if $sappnd_group_flag;
    $flag_types_in_use{$BSD_USER_APPEND_FLAG_ON}    = 1 if $uappnd_group_flag;
    _check_ancestor_conflicts() if ($bsd && keys %flag_types_in_use > 1);
}

# Make sure specified group tag exists.
#
# Special suffix cases:
# groupname:uchg -- only use the subset of groupname that is also in uchg
#     group. This doesn't verify that the intersection is non-null.
# uchg -- only do uchg group.
# groupname:schg -- only use the subset of groupname that is also in schg
#     group. This doesn't verify that the intersection is non-null.
# schg -- only do schg group.
# groupname:uappnd -- only use the subset of groupname that is also in uappnd
#     group (BSD). This doesn't verify that the intersection is non-null.
# uappnd -- only do uappnd group (BSD).
# groupname:+a -- only use the subset of groupname that is also in +a
#     group (Linux). This doesn't verify that the intersection is non-null.
# +a -- only do +a group (Linux).
# groupname:sappnd -- only use the subset of groupname that is also in sappnd
#     group (BSD, root only). This doesn't verify that the intersection is non-null.
# sappnd -- only do sappnd group (BSD, root only).
#
# Plain group name (no suffix): processes all files in the group, applying
# whichever flag (immutable or append-only) is appropriate to each file.
# This means "-g pacct" will set/clear both immutable flags on the
# acct.0-acct.3 files AND the uappnd/+a flag on the acct file, if the
# pacct group includes files with both flag types.
#
# uchg cases:
if ($specified_group_tag) {
    $displayed_verbose_group_msg_flag = 0 if ($verbose_flag);
    if ($bsd && $specified_group_tag =~ /^$BSD_USER_IMMUTABLE_FLAG_ON$|^(.*):$BSD_USER_IMMUTABLE_FLAG_ON$/) {
	if (defined ($1)) {
	    $specified_group_tag = $1;
	    if (!grep { $_ eq $specified_group_tag } @all_group_tags) {
		die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
	    }
	    elsif (!grep { $_ eq $specified_group_tag } @uchg_group_tags) {
		if ($audit_flag && $quiet_flag) {
		    exit 0; # group exists but has no uchg files -- not a blocker
		}
		die "Specified group tag \"$specified_group_tag\" doesn't appear with group tag \"$BSD_USER_IMMUTABLE_FLAG_ON\".\n";
	    }

	    # No-op if already using $BSD_USER_IMMUTABLE_FLAG_ON.
	    if ($lock_flag eq $BSD_SYS_IMMUTABLE_FLAG_ON) {
		$uchg_group_flag = 1;
		$lock_flag = $BSD_USER_IMMUTABLE_FLAG_ON;
		$unlock_flag = "no$lock_flag";
	    }

	    # Must be at least one group using $BSD_USER_IMMUTABLE_FLAG_ON (if they aren't already all implicit).
	    if ($lock_flag ne $BSD_USER_IMMUTABLE_FLAG_ON && !grep { $_ eq $BSD_USER_IMMUTABLE_FLAG_ON } @all_group_tags) {
		die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
	    }
	}
    }
    # schg cases:
    elsif ($bsd && $specified_group_tag =~ /^$BSD_SYS_IMMUTABLE_FLAG_ON$|^(.*):$BSD_SYS_IMMUTABLE_FLAG_ON$/) {
	if (defined ($1)) {
	    $specified_group_tag = $1;
	    if (!grep { $_ eq $specified_group_tag } @all_group_tags) {
		die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
	    }
	    elsif (!grep { $_ eq $specified_group_tag } @schg_group_tags) {
		if ($audit_flag && $quiet_flag) {
		    exit 0; # group exists but has no schg files -- not a blocker
		}
		die "Specified group tag \"$specified_group_tag\" doesn't appear with group tag \"$BSD_SYS_IMMUTABLE_FLAG_ON\".\n";
	    }

	    # No-op if already using $BSD_SYS_IMMUTABLE_FLAG_ON.
	    if ($lock_flag eq $BSD_USER_IMMUTABLE_FLAG_ON) {
		$schg_group_flag = 1;
		$lock_flag = $BSD_SYS_IMMUTABLE_FLAG_ON;
		$unlock_flag = "no$lock_flag";
	    }

	    # Must be at least one group with $BSD_SYS_IMMUTABLE_FLAG_ON (if they aren't already all implicit).
	    if ($lock_flag ne $BSD_SYS_IMMUTABLE_FLAG_ON && !grep { $_ eq $BSD_SYS_IMMUTABLE_FLAG_ON } @all_group_tags) {
		die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
	    }
	}
    }
    # BSD uappnd cases:
    elsif ($bsd && $specified_group_tag =~ /^$BSD_USER_APPEND_FLAG_ON$|^(.*):$BSD_USER_APPEND_FLAG_ON$/) {
	$uappnd_group_specified_flag = 1;
	if (defined ($1)) {
	    $specified_group_tag = $1;
	    if (!grep { $_ eq $specified_group_tag } @all_group_tags) {
		die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
	    }
	    elsif (!grep { $_ eq $specified_group_tag } @uappnd_group_tags) {
		if ($audit_flag && $quiet_flag) {
		    exit 0; # group exists but has no uappnd files -- not a blocker
		}
		die "Specified group tag \"$specified_group_tag\" doesn't appear with group tag \"$BSD_USER_APPEND_FLAG_ON\".\n";
	    }
	}
	elsif (!$uappnd_group_flag) {
	    die "No \"$BSD_USER_APPEND_FLAG_ON\" group tag found in config.\n";
	}
    }
    # Linux +a cases:
    elsif ($linux && $specified_group_tag =~ /^\+a$|^(.*):(\+a)$/) {
	$uappnd_group_specified_flag = 1;
	if (defined ($1)) {
	    $specified_group_tag = $1;
	    if (!grep { $_ eq $specified_group_tag } @all_group_tags) {
		die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
	    }
	    elsif (!grep { $_ eq $specified_group_tag } @uappnd_group_tags) {
		if ($audit_flag && $quiet_flag) {
		    exit 0; # group exists but has no +a files -- not a blocker
		}
		die "Specified group tag \"$specified_group_tag\" doesn't appear with group tag \"+a\".\n";
	    }
	}
	elsif (!$uappnd_group_flag) {
	    die "No \"+a\" group tag found in config.\n";
	}
    }
    # BSD sappnd cases:
    elsif ($bsd && $specified_group_tag =~ /^$BSD_SYS_APPEND_FLAG_ON$|^(.*):$BSD_SYS_APPEND_FLAG_ON$/) {
	die "The sappnd group tag is not available for non-root users.\n" if ($nonroot_flag);
	$sappnd_group_specified_flag = 1;
	if (defined ($1)) {
	    $specified_group_tag = $1;
	    if (!grep { $_ eq $specified_group_tag } @all_group_tags) {
		die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
	    }
	    elsif (!grep { $_ eq $specified_group_tag } @sappnd_group_tags) {
		if ($audit_flag && $quiet_flag) {
		    exit 0; # group exists but has no sappnd files -- not a blocker
		}
		die "Specified group tag \"$specified_group_tag\" doesn't appear with group tag \"$BSD_SYS_APPEND_FLAG_ON\".\n";
	    }
	}
	elsif (!$sappnd_group_flag) {
	    die "No \"$BSD_SYS_APPEND_FLAG_ON\" group tag found in config.\n";
	}
    }
    elsif (!grep { $_ eq $specified_group_tag } @all_group_tags) {
	die "Specified group tag \"$specified_group_tag\" doesn't exist.\n";
    }
}

# Get system security level if BSD, using BSD_SYS_IMMUTABLE_FLAG_ON, or
# there are schg or sappnd groups. Non-root users are restricted to
# uchg/uappnd and cannot have schg/sappnd groups, so this block naturally
# never fires for them.
if ($bsd && ($lock_flag eq $BSD_SYS_IMMUTABLE_FLAG_ON ||
	     $schg_group_flag || $sappnd_group_flag)) {
    my @sysctl_command = split (/\s+/, $BSD_SECURELEVEL_COMMAND);
    open (my $sysctl_fh, '-|', @sysctl_command) or die "Could not run sysctl. $!\n";
    $level = <$sysctl_fh>;
    close ($sysctl_fh);
    chomp ($level);
    if ($^O eq 'darwin') {
	$level =~ s/kern\.securelevel:\s*(\d+)/$1/;
    }
    else {
	$level =~ s/kern\.securelevel\s*=\s*(\d+)/$1/;
    }

    # Warnings and errors.
    if ($level > 0) {
	# For $BSD_SYS_IMMUTABLE_FLAG_ON:
	# If lock, -f (force) overrides securelevel requirement.
	#    If uchg groups in use, they will be locked.
	# If unlock, securelevel must be < 1.
	#    If uchg groups in use, they will be unlocked.
	# Don't die if -a (audit), but warn.
	# Also die if schg group was explicitly specified.
	if ($lock_flag eq $BSD_SYS_IMMUTABLE_FLAG_ON) {
	    print "DEBUG: uchg_group_flag=$uchg_group_flag\n" if ($debug_flag);
	    if ($mode == $LOCK) {
		if ((!$force_flag && !$audit_flag && !$uchg_group_flag) || ($schg_group_specified_flag && !$force_flag && !$audit_flag)) {
		    die "System securelevel is $level; syslock requires -f option or securelevel < 1.\n";
		}
		elsif ($audit_flag) {
		    print "System securelevel is $level; syslock requires -f option or securelevel < 1.\n" unless $quiet_flag;
		    print "Overriding -o to audit schg files as explicitly requested.\n" if ($operational_flag && $schg_group_specified_flag && !$quiet_flag);
		    # With -o, schg files should be skipped in the per-file loop
		    # just as they would be in a normal syslock run without -f.
		    # Set $schg_group_flag so the per-file next guard fires.
		    if ($operational_flag && !$schg_group_specified_flag && $uchg_group_flag && !$force_flag) {
			$schg_group_flag = 1;
		    }
		}
		# Implicit uchg group specified.
		elsif ($uchg_group_flag && !$force_flag) {
		    $schg_group_flag = 1; # necessary to skip schg files
		    
		    if (!$specified_group_tag) {
			$specified_group_tag = $BSD_USER_IMMUTABLE_FLAG_ON;
		    }
		    elsif ($specified_group_tag !~ /$BSD_USER_IMMUTABLE_FLAG_ON/) {
			# Can't implicitly add :uchg if there's no such group.
			if (!grep { $_ eq $specified_group_tag } @uchg_group_tags) {
			    die "System securelevel is $level; syslock requires -f option or securelevel < 1. No $specified_group_tag:$BSD_USER_IMMUTABLE_FLAG_ON to lock.\n" unless $audit_flag;
			    print "System securelevel is $level; syslock requires -f option or securelevel < 1. No $specified_group_tag:$BSD_USER_IMMUTABLE_FLAG_ON to lock.\n" if ($audit_flag && !$quiet_flag);
			}
#			$specified_group_tag .= ':' . $BSD_USER_IMMUTABLE_FLAG_ON
		    }

		    # Here to avoid duplicate in case where above die error occurs.
		    print "System securelevel is $level; syslock requires -f option or securelevel < 1. Locking uchg group only\n" if ($verbose_flag);
		}
	    }
	    else { # mode == UNLOCK
		if ((!$audit_flag && !$uchg_group_flag) || ($schg_group_specified_flag && !$audit_flag)) {
		    die "System securelevel is $level; sysunlock requires securelevel < 1.\n";
		}
		elsif ($audit_flag) {
		    print "System securelevel is $level; sysunlock requires securelevel < 1.\n" unless $quiet_flag;
		    print "Overriding -o to audit schg files as explicitly requested.\n" if ($operational_flag && $schg_group_specified_flag && !$quiet_flag);
		    # With -o, schg files should be skipped in the per-file loop
		    # just as they would be in a normal sysunlock run.
		    # Set $schg_group_flag so the per-file next guard fires.
		    if ($operational_flag && !$schg_group_specified_flag && $uchg_group_flag) {
			$schg_group_flag = 1;
		    }
		}
		# Implicit uchg group specified.
		elsif ($uchg_group_flag) {
		    $schg_group_flag = 1; # necessary to skip schg files
		    
		    if (!$specified_group_tag) {
			$specified_group_tag = $BSD_USER_IMMUTABLE_FLAG_ON;
		    }
		    elsif ($specified_group_tag !~ /$BSD_USER_IMMUTABLE_FLAG_ON/) {
			# Can't implicitly add :uchg if there's no such group.
			if (!grep { $_ eq $specified_group_tag } @uchg_group_tags) {
			    die "System securelevel is $level; sysunlock requires securelevel < 1. No $specified_group_tag:$BSD_USER_IMMUTABLE_FLAG_ON to unlock.\n" unless $audit_flag;
			    print "System securelevel is $level; sysunlock requires securelevel < 1. No $specified_group_tag:$BSD_USER_IMMUTABLE_FLAG_ON to unlock.\n" if ($audit_flag && !$quiet_flag);
			}

#			$specified_group_tag .= ':' . $BSD_USER_IMMUTABLE_FLAG_ON;
		    }

		    # Here to avoid duplicate in case where above die error occurs.
		    print "System securelevel is $level; sysunlock requires securelevel < 1. Unlocking uchg group only.\n" if ($verbose_flag);
		}
	    }
	}
	# For $BSD_USER_IMMUTABLE_FLAG_ON:
	# If unlock, BSD, and using user immutability but there are some schg
	# groups in use and securelevel not < 1, warn if verbose. [Shouldn't this work conversely for system immutability and some uchg?]
	elsif ($schg_group_flag) {
	    print "Warning: System securelevel is $level; sysunlock will not unlock schg groups unless securelevel < 1.\n" if ($verbose_flag && $mode == $UNLOCK && $level > 0);
	    print "Warning: System securelevel is $level; syslock will not lock schg groups unless -f option is used or securelevel < 1.\n" if ($verbose_flag && !$force_flag && $mode == $LOCK && $level > 0);
	}
	# For sappnd: mirrors schg behavior -- both setting and removing require
	# securelevel < 1 or -f for setting. This ensures symmetry: a group with
	# sappnd files that can be locked at a given securelevel can also be
	# unlocked, and vice versa.
	# If lock, -f (force) overrides securelevel requirement.
	#    If uappnd groups in use alongside sappnd, they will be processed.
	# If unlock, securelevel must be < 1.
	# Don't die if -a (audit), but warn.
	# Also die if sappnd group was explicitly specified (without -f for lock).
	if ($sappnd_group_flag) {
	    if ($mode == $LOCK) {
		if ($sappnd_group_specified_flag && !$force_flag && !$audit_flag) {
		    die "System securelevel is $level; syslock requires -f option or securelevel < 1 to set sappnd.\n";
		}
		elsif ($audit_flag) {
		    print "System securelevel is $level; syslock requires -f option or securelevel < 1 to set sappnd.\n" unless $quiet_flag;
		    print "Overriding -o to audit sappnd files as explicitly requested.\n" if ($operational_flag && $sappnd_group_specified_flag && !$quiet_flag);
		}
		elsif (!$force_flag) {
		    # sappnd files will be skipped in the per-file loop.
		    print "Warning: System securelevel is $level; syslock will not set sappnd unless -f option is used or securelevel < 1.\n" if ($verbose_flag);
		}
	    }
	    else { # mode == UNLOCK
		if ($sappnd_group_specified_flag && !$audit_flag) {
		    die "System securelevel is $level; sysunlock requires securelevel < 1 to remove sappnd.\n";
		}
		elsif ($audit_flag) {
		    print "System securelevel is $level; sysunlock requires securelevel < 1 to remove sappnd.\n" unless $quiet_flag;
		    print "Overriding -o to audit sappnd files as explicitly requested.\n" if ($operational_flag && $sappnd_group_specified_flag && !$quiet_flag);
		}
		else {
		    # sappnd files will be skipped in the per-file loop.
		    print "Warning: System securelevel is $level; sysunlock will not remove sappnd unless securelevel < 1.\n" if ($verbose_flag);
		}
	    }
	}
    }
}

# OpenBSD only.
if ($wait_flag) {
    $waiting = 1;
    $start_time = time();
    while ($waiting) {
	# check on library randomization (unlikely to ever be useful since rc.securelevel runs after this is done)
	opendir (my $dir_fh, $RELINK_DIR) || die "Cannot check for library randomization. $!\n";
	$waiting_on_libs = grep (/^$REBUILD_PREFIX\.\w+$/, readdir ($dir_fh));
	closedir ($dir_fh);

	# check on kernel reordering (assumes syslock runs after kernel reordering has begun)
	open (my $checkps_fh, '-|', "$PS -auwx") || die "Cannot check for kernel reordering. $!\n";
	$waiting_on_kernel = 0;
	while (<$checkps_fh>) {
	    if (/$REORDER_KERNEL/) {
		$waiting_on_kernel = 1;
		last;
	    }
	}
	close ($checkps_fh);

	$waiting = $waiting_on_kernel || $waiting_on_libs;
	$reason = sprintf "%s%s%s", $waiting_on_kernel ? 'kernel reordering' : '', ($waiting_on_kernel && $waiting_on_libs) ? ' and ' : '', $waiting_on_libs ? 'library randomization' : '';
	print "Waiting on $reason.\n" if ($verbose_flag);
	sleep 1 if ($waiting);
	if (time() - $start_time > $FIVE_MINUTES) {
	    die "Timeout after five minutes waiting on $reason.\n";
	}
    }
}

# If / happens to be mounted as readonly, make it writeable.
if (!$audit_flag && _root_ro()) {
    $root_was_ro = 1;
    print "mounting / as writable\n" if ($verbose_flag);
    if ($bsd) {
	system ($BSD_MOUNT_COMMAND, @BSD_MOUNT_ARGS) unless ($debug_flag);
    }
    else {
	system ($LINUX_MOUNT_COMMAND, @LINUX_MOUNT_ARGS) unless ($debug_flag);
    }
}

# Accumulates whether any audit mismatch was found; used by -q exit code.
my $any_mismatch = 0;

# Finish unveiling if not auditing. This is still a bit overpermissive
# if an individual group has been specified.
if ($^O eq 'openbsd' && !$audit_flag) {
    foreach $file (@files) {
	unveil ($file, 'rw');
    }
    # Lock any further unveiling.
    unveil ();
}

foreach $file (@files) {
    @file_group_tags = split (/\s+/, $group_tags{$file}) if ($specified_group_tag || $uchg_group_flag || $schg_group_flag || $uappnd_group_flag || $sappnd_group_flag);

    # uchg group is in config
    if ($uchg_group_flag) {
	# If file is in uchg group, temporarily change how we lock and unlock.
	if (grep { $_ eq $BSD_USER_IMMUTABLE_FLAG_ON } @file_group_tags) {
	    $uchg_group_in_use_flag = 1;
	    # Use uchg.
	    $lock_flag = $BSD_USER_IMMUTABLE_FLAG_ON;
	    $unlock_flag = "no$lock_flag";
	}
	# Restore if it's not.
	elsif ($uchg_group_in_use_flag && !grep { $_ eq $BSD_USER_IMMUTABLE_FLAG_ON } @file_group_tags) {
	    # Use schg.
	    $uchg_group_in_use_flag = 0;
	    $lock_flag = $BSD_SYS_IMMUTABLE_FLAG_ON;
	    $unlock_flag = "no$lock_flag";
	}
    }
    # Not an elsif. Can have both groups in use. schg group is in config.
    # We've already verified no config groups have both schg and uchg,
    # which would make this go awry.
    if ($schg_group_flag) {
	# If file is in schg group, check securelevel before changing $lock_flag.
	# Do NOT set $lock_flag = schg before the securelevel next, or it will
	# corrupt $lock_flag for subsequent files in the loop.
	if (grep { $_ eq $BSD_SYS_IMMUTABLE_FLAG_ON } @file_group_tags) {
	    next if ((!$audit_flag || ($operational_flag && !$schg_group_specified_flag))
		     && (!$force_flag || $mode == $UNLOCK) && $level > 0);
	    # Only reach here if we're actually going to process this file.
	    $schg_group_in_use_flag = 1;
	    # Use schg.
	    $lock_flag = $BSD_SYS_IMMUTABLE_FLAG_ON;
	    $unlock_flag = "no$lock_flag";
	}
	# Restore if it's not.
	elsif ($schg_group_in_use_flag && !grep { $_ eq $BSD_SYS_IMMUTABLE_FLAG_ON } @file_group_tags) {
	    # Use uchg.
	    $schg_group_in_use_flag = 0;
	    $lock_flag = $BSD_USER_IMMUTABLE_FLAG_ON;
	    $unlock_flag = "no$lock_flag";
	}
    }

    # uappnd/+a group is in config. When the file is in a uappnd/+a group,
    # switch to the append flag for this file. No securelevel restriction
    # applies to append-only flags. Processed on all invocations including
    # no-arg, so that a plain syslock/sysunlock manages all configured flags.
    #
    # IMPORTANT: the uchg and schg blocks above have already run for this
    # file by the time we get here, so $lock_flag is already correctly set
    # for any uchg/schg file. The uappnd restore branch must NOT overwrite
    # that -- it simply clears $uappnd_group_in_use_flag and lets the
    # uchg/schg blocks' result stand.
    if ($uappnd_group_flag) {
	my $append_tag = $bsd ? $BSD_USER_APPEND_FLAG_ON : '+a';
	if (grep { $_ eq $append_tag } @file_group_tags) {
	    $uappnd_group_in_use_flag = 1;
	    # Switch to append flag.
	    if ($bsd) {
		$lock_flag   = $BSD_USER_APPEND_FLAG_ON;
		$unlock_flag = "no$BSD_USER_APPEND_FLAG_ON";
	    }
	    else {
		$lock_flag   = $LINUX_APPEND_FLAG_ON;
		$unlock_flag = $LINUX_APPEND_FLAG_OFF;
	    }
	}
	# Leaving a uappnd/+a file: clear the flag. The uchg/schg blocks
	# above have already set $lock_flag correctly for this file, so
	# do not restore from any saved value -- just get out of the way.
	elsif ($uappnd_group_in_use_flag) {
	    $uappnd_group_in_use_flag = 0;
	    # If neither uchg nor schg block fired for this file (e.g. it
	    # is a plain implicit-flag file with no explicit uchg/schg tag),
	    # reset $lock_flag to the global immutability default so it is
	    # not left pointing at the append flag.
	    if (!$uchg_group_in_use_flag && !$schg_group_in_use_flag) {
		$lock_flag   = $global_lock_flag;
		$unlock_flag = $global_unlock_flag;
	    }
	    # Otherwise uchg/schg already set $lock_flag correctly -- leave it.
	}
    }

    # uappnd/+a files are processed like all other files -- no skip guard.
    # The append-only flag switching above handles them correctly whether
    # a group is specified or not.

    # sappnd group is in config (BSD only, root only). Both setting and
    # removal require securelevel < 1 (or -f for setting), mirroring schg.
    if ($sappnd_group_flag) {
	if (grep { $_ eq $BSD_SYS_APPEND_FLAG_ON } @file_group_tags) {
	    next if ((!$audit_flag || ($operational_flag && !$sappnd_group_specified_flag))
		     && (!$force_flag || $mode == $UNLOCK) && $level > 0);
	    # Only reach here if we're going to process this file.
	    $sappnd_group_in_use_flag = 1;
	    $lock_flag   = $BSD_SYS_APPEND_FLAG_ON;
	    $unlock_flag = "no$BSD_SYS_APPEND_FLAG_ON";
	}
	# Restore if it's not a sappnd file.
	elsif ($sappnd_group_in_use_flag) {
	    $sappnd_group_in_use_flag = 0;
	    $lock_flag   = $global_lock_flag;
	    $unlock_flag = $global_unlock_flag;
	}
    }

    if ((!$singleuser_flag || $singleuser_unlocked{$file}) &&
	(!$specified_group_tag || _in_group ($specified_group_tag, $uchg_group_specified_flag, $schg_group_specified_flag, $sappnd_group_specified_flag, @file_group_tags))) {
	if ($mode == $LOCK) {
	    if ($specified_group_tag &&
		$verbose_flag &&
		!$displayed_verbose_group_msg_flag) {
		print "locking group $specified_group_tag";
		print ":$BSD_USER_IMMUTABLE_FLAG_ON" if ($uchg_group_specified_flag && $specified_group_tag ne $BSD_USER_IMMUTABLE_FLAG_ON);
		print ":$BSD_SYS_IMMUTABLE_FLAG_ON" if ($schg_group_specified_flag && $specified_group_tag ne $BSD_SYS_IMMUTABLE_FLAG_ON);
		if ($uappnd_group_specified_flag) {
		    my $atag = $bsd ? $BSD_USER_APPEND_FLAG_ON : '+a';
		    print ":$atag" if ($specified_group_tag ne $atag);
		}
		print ":$BSD_SYS_APPEND_FLAG_ON" if ($sappnd_group_specified_flag && $specified_group_tag ne $BSD_SYS_APPEND_FLAG_ON);
		print "\n";
		$displayed_verbose_group_msg_flag = 1;
	    }
	    if ($audit_flag) {
		# Report on what is unlocked that would be locked by command.
		$any_mismatch |= _audit_file ($file, $lock_flag, $dont_recurse{$file}, $dont_lock_top_level_dir{$file}, $files_only{$file});
		exit 1 if ($quiet_flag && $any_mismatch);
	    }
	    elsif (-d $file) {
		if ($files_only{$file}) {
		    # Lock the directory itself and direct file contents only,
		    # skipping subdirectories entirely.
		    print "locking $file (files only)\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    opendir (my $dh, $file) or die "Cannot open directory $file. $!\n";
		    my @entries = grep { !/^\.\.?$/ } readdir ($dh);
		    closedir ($dh);
		    foreach my $entry (@entries) {
			next unless $entry =~ /^([-\w.+]+)$/;
			my $clean_entry = "$file/$1";
			next if -d $clean_entry;  # skip subdirectories
			system (@lock_command, $lock_flag, $clean_entry);
		    }
		    system (@lock_command, $lock_flag, $file);
		}
		elsif ($dont_recurse{$file}) {
		    print "locking $file/*\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    opendir (my $dh, $file) or die "Cannot open directory $file. $!\n";
		    my @entries = grep { !/^\.\.?$/ } readdir ($dh);
		    closedir ($dh);
		    foreach my $entry (@entries) {
			next unless $entry =~ /^([-\w.+]+)$/;
			my $clean_entry = "$file/$1";
			next unless -e $clean_entry;
			system (@lock_command, $lock_flag, $clean_entry);
		    }
		    print "locking $file\n" if ($verbose_flag && !$dont_lock_top_level_dir{$file});
		    system (@lock_command, $lock_flag, $file) unless $dont_lock_top_level_dir{$file};
		}
		elsif ($dont_lock_top_level_dir{$file}) {
		    print "recursively locking $file/*\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    opendir (my $dh, $file) or die "Cannot open directory $file. $!\n";
		    my @entries = grep { !/^\.\.?$/ } readdir ($dh);
		    closedir ($dh);
		    foreach my $entry (@entries) {
			next unless $entry =~ /^([-\w.+]+)$/;
			_recurse_lock_or_unlock ("$file/$1", $lock_flag);
		    }
		}
		else {
		    print "recursively locking $file\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    system (@lock_command, $RECURSE_FLAG, $lock_flag, $file);
		}
	    }
	    elsif (-e $file) {
		print "locking $file\n" if ($verbose_flag);
		print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		system (@lock_command, $lock_flag, $file);
	    }
	    else {
		print "Not locking. File path in config inaccessible or does not exist. $!. $file\n";
	    }
	}
	else {
	    if ($specified_group_tag &&
		$verbose_flag &&
		!$displayed_verbose_group_msg_flag) {
		print "unlocking group $specified_group_tag";
		print ":$BSD_USER_IMMUTABLE_FLAG_ON" if ($uchg_group_specified_flag && $specified_group_tag ne $BSD_USER_IMMUTABLE_FLAG_ON);
		print ":$BSD_SYS_IMMUTABLE_FLAG_ON" if ($schg_group_specified_flag && $specified_group_tag ne $BSD_SYS_IMMUTABLE_FLAG_ON);
		if ($uappnd_group_specified_flag) {
		    my $atag = $bsd ? $BSD_USER_APPEND_FLAG_ON : '+a';
		    print ":$atag" if ($specified_group_tag ne $atag);
		}
		print ":$BSD_SYS_APPEND_FLAG_ON" if ($sappnd_group_specified_flag && $specified_group_tag ne $BSD_SYS_APPEND_FLAG_ON);
		print "\n";
		$displayed_verbose_group_msg_flag = 1;
	    }
	    if ($audit_flag) {
		# Report on what is locked that would be unlocked by command.
		$any_mismatch |= _audit_file ($file, $unlock_flag, $dont_recurse{$file}, $dont_lock_top_level_dir{$file}, $files_only{$file});
		exit 1 if ($quiet_flag && $any_mismatch);
	    }
	    elsif (-d $file) {
		if ($files_only{$file}) {
		    # Unlock the directory itself and direct file contents only,
		    # skipping subdirectories entirely.
		    print "unlocking $file (files only)\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    opendir (my $dh, $file) or die "Cannot open directory $file. $!\n";
		    my @entries = grep { !/^\.\.?$/ } readdir ($dh);
		    closedir ($dh);
		    foreach my $entry (@entries) {
			next unless $entry =~ /^([-\w.+]+)$/;
			my $clean_entry = "$file/$1";
			next if -d $clean_entry;  # skip subdirectories
			system (@lock_command, $unlock_flag, $clean_entry);
		    }
		    system (@lock_command, $unlock_flag, $file);
		}
		elsif ($dont_recurse{$file}) {
		    print "unlocking $file\n" if ($verbose_flag && !$dont_lock_top_level_dir{$file});
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    system (@lock_command, $unlock_flag, $file) unless $dont_lock_top_level_dir{$file};
		    print "unlocking $file/*\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    foreach my $entry (glob("$file/*")) {
			next unless $entry =~ m{^([-\w./+]+)$};
			my $clean_entry = $1;
			next unless -e $clean_entry;
			system (@lock_command, $unlock_flag, $clean_entry);
		    }
		}
		elsif ($dont_lock_top_level_dir{$file}) {
		    print "recursively unlocking $file/*\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    foreach my $entry (glob("$file/*")) {
			next unless $entry =~ m{^([-\w./+]+)$};
			_recurse_lock_or_unlock ($1, $unlock_flag);
		    }
		}
		else {
		    print "recursively unlocking $file\n" if ($verbose_flag);
		    print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		    system (@lock_command, $RECURSE_FLAG, $unlock_flag, $file);
		}
	    }
	    elsif (-e $file) {
		print "unlocking $file\n" if ($verbose_flag);
		print "DEBUG: file groups: @file_group_tags\n" if ($debug_flag);
		system (@lock_command, $unlock_flag, $file);
	    }
	    else {
		print "Not unlocking. File path in config inaccessible or does not exist. $!. $file\n";
	    }
	}
    }
}

# If / was mounted as readonly, make it readonly again.
if ($root_was_ro) {
    print "mounting / as readonly\n" if ($verbose_flag);
    if ($bsd) {
	system ($BSD_MOUNT_COMMAND, @BSD_MOUNT_RO_ARGS) unless ($debug_flag);
    }
    else {
	system ($LINUX_MOUNT_COMMAND, @LINUX_MOUNT_RO_ARGS) unless ($debug_flag);
    }
}

# -q (quiet) audit: exit 0 if filesystem matches expected state, 1 if not.
if ($audit_flag && $quiet_flag) {
    exit ($any_mismatch ? 1 : 0);
}

# Non-quiet audit: print summary message when all files are in expected state.
if ($audit_flag && !$any_mismatch) {
    if ($operational_flag && $level > 0) {
	print "Audit complete: all actionable files are in the expected state (schg/sappnd files skipped at securelevel $level).\n";
    }
    else {
	print "Audit complete: all files are in the expected state.\n";
    }
}

# Subroutine to return the effective BSD flag type for a path based on
# its group tags, falling back to the global lock flag.
sub _effective_flag_type {
    my ($path) = @_;
    my $tags = $group_tags{$path} // '';
    my @tag_list = split (/\s+/, $tags);
    return $BSD_SYS_IMMUTABLE_FLAG_ON  if grep { $_ eq $BSD_SYS_IMMUTABLE_FLAG_ON }  @tag_list;
    return $BSD_USER_IMMUTABLE_FLAG_ON if grep { $_ eq $BSD_USER_IMMUTABLE_FLAG_ON } @tag_list;
    return $BSD_SYS_APPEND_FLAG_ON     if grep { $_ eq $BSD_SYS_APPEND_FLAG_ON }     @tag_list;
    return $BSD_USER_APPEND_FLAG_ON    if grep { $_ eq $BSD_USER_APPEND_FLAG_ON }    @tag_list;
    return $global_lock_flag;
}

# Subroutine to detect ancestor/descendant conflicts: cases where the same
# file path would be processed by two config entries using different flag
# types, which could result in multiple flags being set on the same file.
# Only called on BSD when more than one flag type is in use.
# Warns for each conflict found; does not die, to allow the admin to
# decide whether the overlap is intentional (e.g. harmless on Linux-style
# configs accidentally used on BSD).
sub _check_ancestor_conflicts {
    # Build a hash of config paths for O(1) ancestor lookup.
    my %path_in_config;
    for my $p (@files) {
        $path_in_config{$p} = 1;
    }

    for my $file (@files) {
        # Skip symlinks -- ancestor detection through symlinks is unreliable.
        if (-l $file) {
            print "Warning: $file is a symlink; ancestor conflict check skipped.\n";
            next;
        }

        my $file_flag = _effective_flag_type ($file);

        # Walk up the directory tree checking each ancestor.
        my $ancestor = $file;
        while ($ancestor =~ s{/[^/]+$}{} && $ancestor ne '') {
            next unless $path_in_config{$ancestor};

            my $ancestor_flag = _effective_flag_type ($ancestor);

            # Only a conflict if flag types differ.
            next if $ancestor_flag eq $file_flag;

            # Determine whether the ancestor's prefix would actually
            # cause it to process $file.
            my $would_process = 0;
            my $is_direct_child = ($file =~ m{^\Q$ancestor\E/[^/]+$});

            if ($files_only{$ancestor}) {
                # = prefix: only direct non-directory children.
                $would_process = ($is_direct_child && !-d $file);
            }
            elsif ($dont_recurse{$ancestor}) {
                # + prefix: only direct children (files and dirs).
                $would_process = $is_direct_child;
            }
            elsif ($dont_lock_top_level_dir{$ancestor}) {
                # - prefix: all contents recursively.
                $would_process = 1;
            }
            else {
                # No prefix: all contents recursively.
                $would_process = 1;
            }

            if ($would_process) {
                print "Warning: $file (flag type: $file_flag) would also be " .
                      "processed by ancestor $ancestor (flag type: $ancestor_flag). " .
                      "Both flags could be set on the same path.\n";
            }
        }
    }
}

# Subroutine to return 1 if file is in specified group, 0 otherwise.
sub _in_group {
    my ($specified_group_tag,
	$uchg_group_specified_flag,
	$schg_group_specified_flag,
	$sappnd_group_specified_flag,
	@file_group_tags) = @_;

    # If it's in the group ...
    if (grep { $_ eq $specified_group_tag } @file_group_tags) {
	# and we don't care about uchg, schg, uappnd/+a, or sappnd...
	return 1 if (!$uchg_group_specified_flag && !$schg_group_specified_flag && !$uappnd_group_specified_flag && !$sappnd_group_specified_flag);
	# or the specified group is already uchg/schg...
	return 1 if (($uchg_group_specified_flag &&
		      $specified_group_tag eq $BSD_USER_IMMUTABLE_FLAG_ON) ||
		     ($schg_group_specified_flag &&
		      $specified_group_tag eq $BSD_SYS_IMMUTABLE_FLAG_ON));
	# or it's already the uappnd/+a group itself...
	return 1 if ($uappnd_group_specified_flag &&
		     (($bsd && $specified_group_tag eq $BSD_USER_APPEND_FLAG_ON) ||
		      ($linux && $specified_group_tag eq '+a')));
	# or it's already the sappnd group itself...
	return 1 if ($sappnd_group_specified_flag &&
		     $specified_group_tag eq $BSD_SYS_APPEND_FLAG_ON);
	# or it's also in the uchg/schg group...
	return 1 if (($uchg_group_specified_flag &&
		      grep { $_ eq $BSD_USER_IMMUTABLE_FLAG_ON } @file_group_tags) ||
		     ($schg_group_specified_flag &&
		      grep { $_ eq $BSD_SYS_IMMUTABLE_FLAG_ON } @file_group_tags));
	# or it's also in the uappnd/+a group...
	return 1 if ($uappnd_group_specified_flag &&
		     (($bsd && grep { $_ eq $BSD_USER_APPEND_FLAG_ON } @file_group_tags) ||
		      ($linux && grep { $_ eq '+a' } @file_group_tags)));
	# or it's also in the sappnd group...
	return 1 if ($sappnd_group_specified_flag &&
		     grep { $_ eq $BSD_SYS_APPEND_FLAG_ON } @file_group_tags);
    }

    # Otherwise.
    return 0;
}

# Subroutine to warn about unusual characters or nonexistent file paths in config
# and to untaint the result.
sub _file_warnings {
    my ($path, $options) = @_;  # $options is optional; passed when a prefix char was used
    
    if ($path =~ /[;&|`\$\(\)<>]/) {
        die "Shell metacharacters in config file path not allowed. $path\n";
    }
    # Reject paths that aren't absolute
    if ($path !~ m{^/}) {
        die "Only absolute paths allowed in config file. $path\n";
    }
    # Reject directory traversal.
    if ($path =~ /\.\./) {
        die "Path traversal (..) not allowed in config file. $path\n";
    }
    # Reject null bytes.
    if ($path =~ /\0/) {
        die "Null bytes not allowed in config file path. $path\n";
    }

    # Untaint path.
    my $clean_path;
    if ($path =~ m{^([-\w./+]+)$}) {
        $clean_path = $1;  # Untainted
    }
    else {
        die "Path contains unsafe characters after validation. $path\n";
    }

    # Ensure existence.
    if (!-e $clean_path) {
	print "Warning: File path in config inaccessible or does not exist. $!. $clean_path\n";
    }
    # Warn about symlinks.
    if ($linux && -l $clean_path) {
	print "Warning: File path in config is a symlink. $clean_path\n";
    }
    # Warn if a directory-only prefix (+, -, =) is used on a non-directory path.
    if (defined $options && $options =~ /[-+=]/ && -e $clean_path && !-d $clean_path) {
	print "Warning: prefix '$options' has no effect on non-directory path $clean_path\n";
    }
    # For non-root BSD users, warn if the file is not owned by the invoking user.
    if ($nonroot_flag && -e $clean_path) {
        my @fstat = stat ($clean_path);
        if (@fstat && $fstat[4] != $>) {
            print "Warning: $clean_path is not owned by uid $>; chflags will likely fail.\n";
        }
    }

    return $clean_path;
}

# Subroutine to validate and expand a glob pattern from the config file.
# Glob characters (*, ?, []) are only permitted in the final path component
# (the filename portion after the last /). The directory prefix must be a
# literal absolute path with no wildcards. A bare /* filename (the entire
# filename component is just *) is rejected -- use directory syntax instead.
# Each expanded path is passed through _file_warnings for validation and
# untainting. Returns a list of expanded, validated paths.
sub _expand_glob {
    my ($pattern, $prefix_opts) = @_;
    my @results;

    # Reject shell metacharacters (other than the glob chars we handle).
    if ($pattern =~ /[;&|`\$\(\)<>]/) {
        die "Shell metacharacters in config file glob pattern not allowed. $pattern\n";
    }
    # Must be an absolute path.
    if ($pattern !~ m{^/}) {
        die "Only absolute paths allowed in config file. $pattern\n";
    }
    # Reject directory traversal.
    if ($pattern =~ /\.\./) {
        die "Path traversal (..) not allowed in config file. $pattern\n";
    }
    # Reject null bytes.
    if ($pattern =~ /\0/) {
        die "Null bytes not allowed in config file glob pattern. $pattern\n";
    }

    # Split into directory prefix and filename pattern.
    # The directory prefix is everything up to and including the last /.
    # The filename component is everything after the last /.
    my ($dir_prefix, $filename_pat);
    if ($pattern =~ m{^(.*/)([^/]+)$}) {
        $dir_prefix   = $1;
        $filename_pat = $2;
    }
    else {
        die "Cannot parse glob pattern into directory and filename components. $pattern\n";
    }

    # Reject wildcards in the directory prefix.
    if ($dir_prefix =~ /[*?\[]/) {
        die "Glob characters are only permitted in the filename component, not the directory path. $pattern\n";
    }

    # Reject a bare * as the entire filename component (use directory syntax instead).
    if ($filename_pat eq '*') {
        die "Bare /* glob not permitted (use directory syntax to lock all files in a directory). $pattern\n";
    }

    # Validate the directory prefix: must consist only of safe characters.
    unless ($dir_prefix =~ m{^([-\w./+]+)$}) {
        die "Directory prefix in glob pattern contains unsafe characters. $pattern\n";
    }
    my $clean_dir = $1;  # Untainted directory prefix.

    # Verify the directory exists.
    unless (-d $clean_dir) {
        print "Warning: Directory in glob pattern does not exist. $clean_dir\n";
        return ();
    }

    # Validate filename pattern characters: word chars, dots, hyphens, plus,
    # and the glob metacharacters *, ?, [, ].
    unless ($filename_pat =~ m{^([-\w.+*?\[\]]+)$}) {
        die "Filename pattern in glob contains unsafe characters. $pattern\n";
    }
    my $clean_pat = $1;  # Untainted filename pattern.

    # Expand the glob. Use the cleaned components to avoid any taint issues.
    my $full_pattern = $clean_dir . $clean_pat;
    my @matches = glob ($full_pattern);

    if (!@matches) {
        print "Warning: Glob pattern in config matches no files. $full_pattern\n";
        return ();
    }

    # Validate and untaint each expanded path via _file_warnings.
    for my $match (@matches) {
        # Skip directories when glob is used (directories should be listed
        # explicitly to make the recursion/no-recursion intent clear).
        if (-d $match) {
            print "Warning: Glob pattern matched directory, skipping. $match\n";
            next;
        }
        my $clean_match = _file_warnings ($match, $prefix_opts);
        push (@results, $clean_match);
        print "DEBUG: glob expanded: $full_pattern -> $clean_match\n" if ($debug_flag);
    }

    if (!@results) {
        print "Warning: Glob pattern matched no files after filtering. $full_pattern\n";
    }

    return @results;
}

# Subroutine to determine if / is mounted read-only.
sub _root_ro {
    my ($mount_output, $mount_opts_list, @mount_opts);
    my $mount_cmd = $bsd ? $BSD_MOUNT_COMMAND : $LINUX_MOUNT_COMMAND;

    open (my $mount_fh, '-|', $mount_cmd) or die "Cannot run mount. $!\n";
    $mount_output = do { local $/; <$mount_fh> }; # slurp entire output
    close ($mount_fh);

    if ($mount_output =~ /\/.* on \/ type.*\((.*)\)/) {
	$mount_opts_list = $1;
	@mount_opts = split (/,/, $mount_opts_list);
	return 1 if (grep { $_ eq 'ro' } @mount_opts);
    }

    return 0;
}

# Subroutine to tell if a file is on a fuse or msdos filesystem (where
# Linux lsattr won't work). (This code from sigtree.pl with slight
# modification.))
sub _on_nonstd_fs {
    my ($file) = @_;
    my $fs_type;

    my $STAT = '/usr/bin/stat';

    open (my $stat_fh, '-|', $STAT, '-f', '-c', '%T', $file)
	or die "Cannot run stat on $file. $!\n";
    $fs_type = <$stat_fh>;
    close ($stat_fh);
    chomp ($fs_type);
    return 1 if ($fs_type eq 'fuse' || $fs_type eq 'msdos' ||
		 $fs_type eq 'tmpfs' || $fs_type eq 'mqueue' ||
		 $fs_type eq 'hugetlbfs' || $fs_type eq 'proc');
#    return 1 if ($fs_type ne 'ext2/ext3');
}

# Subroutine to compare actual to desired flags.
sub _desired_flag_mismatch {
    my ($file, $desired_flag, $other_flag) = @_;
    my ($flags, $perms, $nlinks, $uid, $gid);

    if ($bsd) {
	my $LS = '/bin/ls';
	my @ls_args = $^O eq 'darwin' ? ('-l', '-O', '-d') : ('-l', '-o', '-d');

	open (my $ls_fh, '-|', $LS, @ls_args, $file)
	    or die "Cannot run ls on $file. $!\n";
	$flags = <$ls_fh>;
	close ($ls_fh);

        ($perms, $nlinks, $uid, $gid, $flags) = split (/\s+/, $flags);

	# flags may be a comma-separated list (e.g. "uchg,compressed" on macOS).
	# Split and check only for the specific locking flag of interest,
	# ignoring unrelated flags like compressed, restricted, datavault, etc.
	my @flag_list = ($flags eq '-') ? () : split (/,/, $flags);

	# All BSD locking-related flags. Used to detect "locked by something
	# else" when auditing for an unlocked state.
	my @all_lock_flags = ($BSD_SYS_IMMUTABLE_FLAG_ON,  # schg
			      $BSD_USER_IMMUTABLE_FLAG_ON, # uchg
			      $BSD_SYS_APPEND_FLAG_ON,     # sappnd
			      $BSD_USER_APPEND_FLAG_ON);   # uappnd

	if ($desired_flag =~ /^no(.+)$/) {
	    my $bare_flag = $1;
	    # Desired: flag should NOT be set. Mismatch if the specific flag
	    # is present, OR if any other locking flag is present -- a file
	    # locked by any means cannot be written to regardless of which
	    # specific flag syslock manages.
	    return 0 unless (grep { $_ eq $bare_flag } @flag_list) ||
			    (grep { my $f = $_; grep { $_ eq $f } @flag_list } @all_lock_flags);
	}
	else {
	    # Desired: flag SHOULD be set. No mismatch only if the expected
	    # flag is present AND no other locking flag is also present
	    # (an unexpected additional constraint is also a discrepancy).
	    return 0 if  (grep { $_ eq $desired_flag } @flag_list) &&
			!(grep { my $f = $_; $f ne $desired_flag &&
				 (grep { $_ eq $f } @flag_list) } @all_lock_flags);
	}
	# Return the full flags string so audit output is informative.
	return $flags;
    }
    else {
	my $LSATTR = '/usr/bin/lsattr';

	open (my $lsattr_fh, '-|', $LSATTR, '-d', $file)
	    or die "Cannot run lsattr on $file. $!\n";
	$flags = <$lsattr_fh>;
	close ($lsattr_fh);
	($flags) = split (/\s+/, $flags);

	# For Linux, the locking-relevant flags are i (immutable) and a (append-only).
	# "No mismatch" for a positive flag means exactly that flag is set and the
	# other locking flag is absent. "No mismatch" for a negative flag means
	# neither locking flag is present -- a file locked by any means is not
	# writable regardless of which specific flag is expected.
	if ($desired_flag =~ /\+i/) {
	    return 0 if ($flags =~ /i/ && $flags !~ /a/);
	}
	elsif ($desired_flag =~ /-i/) {
	    return 0 if ($flags !~ /i/ && $flags !~ /a/);
	}
	elsif ($desired_flag =~ /\+a/) {
	    return 0 if ($flags =~ /a/ && $flags !~ /i/);
	}
	elsif ($desired_flag =~ /-a/) {
	    return 0 if ($flags !~ /a/ && $flags !~ /i/);
	}
	# Return the flags string with dashes stripped for readability,
	# preserving all flag characters so the output is fully informative.
	$flags =~ s/^-+$/-/;
	$flags =~ s/-//g if ($flags ne '-');
	return $flags;
    }
}

# Subroutine to recursively lock or unlock files in subdirectories.
sub _recurse_lock_or_unlock {
    my ($path, $flag) = @_;
    return if -l $path; # skip symlinks
    if (-d $path) {
	opendir (my $dh, $path) or return;
	my @entries = grep(!/^\.$|^\.\.$/, readdir($dh));
	closedir ($dh);
	for (@entries) {
	    # Untaint readdir() results before constructing paths for system().
	    next unless /^([-\w.+]+)$/;
	    my $entry = $1;
	    _recurse_lock_or_unlock ("$path/$entry", $flag);
	}
	system (@lock_command, $flag, $path);
    }
    elsif (-e $path) {
	system (@lock_command, $flag, $path);
    }
}

# Subroutine to report on what's not in the intended state.
# (This currently only looks at the specific flag for BSD, schg/uchg/uappnd,
# and will call it "unlocked" if it's only locked with the one not checked.)
sub _audit_file {
    my ($file, $desired_flag, $dont_recurse_flag, $dont_lock_top_level_dir_flag, $files_only_flag, $recurse_level) = @_;
    my ($flags, $desired_state, @subfiles, $subfile, $printed_something, $spaces);
    my $found_mismatch = 0;

    if ($desired_flag =~ /^no(.*)$/) {
	$desired_state = 'not append-only' if ($1 eq $BSD_USER_APPEND_FLAG_ON);
	$desired_state = 'not sys-append-only' if ($1 eq $BSD_SYS_APPEND_FLAG_ON);
	$desired_state = 'unlocked' if ($1 ne $BSD_USER_APPEND_FLAG_ON && $1 ne $BSD_SYS_APPEND_FLAG_ON);
    }
    elsif ($desired_flag eq '-i') {
	$desired_state = 'unlocked';
    }
    elsif ($desired_flag eq '+i') {
	$desired_state = 'locked';
    }
    elsif ($desired_flag eq '-a') {
	$desired_state = 'not append-only';
    }
    elsif ($desired_flag eq '+a') {
	$desired_state = 'append-only';
    }
    elsif ($desired_flag eq $BSD_USER_APPEND_FLAG_ON) {
	$desired_state = 'append-only';
    }
    elsif ($desired_flag eq $BSD_SYS_APPEND_FLAG_ON) {
	$desired_state = 'sys-append-only';
    }
    else {
	$desired_state = 'locked with ' . $desired_flag;
    }

    $recurse_level = 0 if (!defined ($recurse_level));

    $printed_something = 0;
    $spaces = $recurse_level * 3;

    if (-l $file || -c $file) {
	print "Warning: ignoring symlink. $file\n" if (-l $file && !$quiet_flag);
	print "Warning: ignoring special character file. $file\n" if (-c $file && !$quiet_flag);
    }
    elsif ($linux && (_on_nonstd_fs ($file))) {
	print "Warning: ignoring file on non-standard mounted filesystem. $file\n" if (_on_nonstd_fs ($file) && !$quiet_flag);
    }
    elsif (-d $file) {
	# We care about the dir itself.
	if (!$dont_lock_top_level_dir_flag) {
	    if ($flags = _desired_flag_mismatch ($file, $desired_flag)) {
		print ' 'x$spaces, "$file (should be $desired_state, flags=$flags)\n" unless $quiet_flag;
		$printed_something = 1;
		$found_mismatch = 1;
		return $found_mismatch if $quiet_flag;
	    }
	}
	# Check subdir contents.
	opendir (my $dir_fh, $file) || die "Cannot open directory. $!. $file\n";
	@subfiles = grep (!/^\.{1,2}$/, readdir ($dir_fh));
	closedir ($dir_fh);

	foreach $subfile (@subfiles) {
	    # Untaint readdir() result.
	    next unless $subfile =~ /^([-\w.+]+)$/;
	    $subfile = $1;
	    if (-l "$file/$subfile" || -c "$file/$subfile") {
		# Ignore links and special character files.
		next;
	    }
	    if ($linux && _on_nonstd_fs ("$file/$subfile")) {
		# Ignore files on non-standard mounted filesystems.
		next;
	    }
	    # files_only: skip subdirectories entirely.
	    elsif (-d "$file/$subfile" && $files_only_flag) {
		next;
	    }
	    # If we need to recurse.
	    elsif (-d "$file/$subfile" && !$dont_recurse_flag) {
		$recurse_level++;
		$found_mismatch |= _audit_file ("$file/$subfile", $desired_flag, $dont_recurse_flag, 0, 0, $recurse_level);
		$recurse_level--;
		return $found_mismatch if ($quiet_flag && $found_mismatch);
	    }
	    elsif ($flags = _desired_flag_mismatch ("$file/$subfile", $desired_flag)) {
		if (!$printed_something && !$quiet_flag) {
		    print ' 'x$spaces, "In $file/:\n";
		    $printed_something = 1;
		}
		print ' 'x$spaces, "   $subfile (should be $desired_state, flags=$flags)\n" unless $quiet_flag;
		$found_mismatch = 1;
		return $found_mismatch if $quiet_flag;
	    }
	}
    }
    # If a file, just audit the file.
    elsif (-e $file) {
	if ($flags = _desired_flag_mismatch ($file, $desired_flag)) {
	    print "$file (should be $desired_state, flags=$flags)\n" unless $quiet_flag;
	    $found_mismatch = 1;
	    return $found_mismatch if $quiet_flag;
	}
    }
    # If nonexistent, just report that.
    else {
	print "File path in config inaccessible or does not exist. $!. $file\n" unless $quiet_flag;
	# A nonexistent file is not a flag mismatch per se; don't count as mismatch.
    }

    return $found_mismatch;
}
