#+##############################################################################
#                                                                              #
# File: No/Worries/Stat.pm                                                     #
#                                                                              #
# Description: stat() handling without worries                                 #
#                                                                              #
#-##############################################################################

#
# module definition
#

package No::Worries::Stat;
use strict;
use warnings;
our $VERSION  = "1.6";
our $REVISION = sprintf("%d.%02d", q$Revision: 1.12 $ =~ /(\d+)\.(\d+)/);

#
# used modules
#

use Fcntl qw(:mode);
use No::Worries::Die qw(dief);
use No::Worries::Export qw(export_control);
use Params::Validate qw(validate :types);

#
# constants
#

use constant ST_DEV     =>  0;     # ID of device containing file
use constant ST_INO     =>  1;     # inode number
use constant ST_MODE    =>  2;     # protection
use constant ST_NLINK   =>  3;     # number of hard links
use constant ST_UID     =>  4;     # user ID of owner
use constant ST_GID     =>  5;     # group ID of owner
use constant ST_RDEV    =>  6;     # device ID (if special file)
use constant ST_SIZE    =>  7;     # total size, in bytes
use constant ST_ATIME   =>  8;     # time of last access
use constant ST_MTIME   =>  9;     # time of last modification
use constant ST_CTIME   => 10;     # time of last status change
use constant ST_BLKSIZE => 11;     # blocksize for filesystem I/O
use constant ST_BLOCKS  => 12;     # number of 512B blocks allocated

use constant _IMODE => oct(7777);  # all mode bits
use constant _IBITS => 12;         # number of mode bits

#
# global variables
#

our(
    @_Mode2Type,  # mode (shifted) to file type
    %_CachedUid,  # cached uid from getpwnam()
    %_CachedGid,  # cached gid from getgrnam()
);

#
# check user option and set uid and message accordingly
#

sub _check_user ($$) {
    my($option, $message) = @_;
    my($user);

    $user = $option->{user};
    return unless defined($user);
    if ($user =~ /^\d+$/) {
        $option->{uid} = $user;
    } else {
        unless (exists($_CachedUid{$user})) {
            $_CachedUid{$user} = getpwnam($user);
            dief("unknown user: %s", $user)
                unless defined($_CachedUid{$user});
        }
        $option->{uid} = $_CachedUid{$user};
    }
    $message->{user} = "user($user)";
}

#
# check group option and set gid and message accordingly
#

sub _check_group ($$) {
    my($option, $message) = @_;
    my($group);

    $group = $option->{group};
    return unless defined($group);
    if ($group =~ /^\d+$/) {
        $option->{gid} = $group;
    } else {
        unless (exists($_CachedGid{$group})) {
            $_CachedGid{$group} = getgrnam($group);
            dief("unknown group: %s", $group)
                unless defined($_CachedGid{$group});
        }
        $option->{gid} = $_CachedGid{$group};
    }
    $message->{group} = "group($group)";
}

#
# check the mode option and set mode_set, mode_clear and message accordingly
#

sub _check_mode ($$) {
    my($option, $message) = @_;
    my($mode, $action, $number);

    $mode = $option->{mode};
    return unless defined($mode);
    if ($mode =~ /^([\+\-])?(\d+)$/) {
        $action = $1 || "";
        $number = substr($2, 0, 1) eq "0" ? oct($2) : ($2+0);
        # use the canonical form for the message
        $mode = sprintf("%s%05o", $action, $number);
        if ($action eq "+") {
            # check that at least these bits are set
            $option->{mode_set} = $number;
            $option->{mode_clear} = 0;
        } elsif ($action eq "-") {
            # check that at least these bits are cleared
            $option->{mode_set} = 0;
            $option->{mode_clear} = $number;
        } else {
            # check that these bits are exactly the ones set
            $option->{mode_set} = $number;
            $option->{mode_clear} = _IMODE;
        }
    } else {
        dief("invalid mode: %s", $mode);
    }
    $message->{mode} = "mode($mode)";
}

#
# check the mtime option and set message accordingly
#

sub _check_mtime ($$) {
    my($option, $message) = @_;
    my($mtime);

    $mtime = $option->{mtime};
    return unless defined($mtime);
    $message->{mtime} = "mtime($mtime)";
}

#
# ensure proper ownership
#

sub _ensure_owner ($$$$) {
    my($path, $stat, $option, $message) = @_;
    my(@todo);

    @todo = ();
    if ($message->{user} and $stat->[ST_UID] != $option->{uid}) {
        $stat->[ST_UID] = $option->{uid};
        push(@todo, $message->{user});
    }
    if ($message->{group} and $stat->[ST_GID] != $option->{gid}) {
        $stat->[ST_GID] = $option->{gid};
        push(@todo, $message->{group});
    }
    return(0) unless @todo and $option->{callback}->($path, "@todo");
    chown($stat->[ST_UID], $stat->[ST_GID], $path)
        or dief("cannot chown(%d, %d, %s): %s",
                $stat->[ST_UID], $stat->[ST_GID], $path, $!);
    return(1)
}

#
# ensure proper permissions
#

sub _ensure_mode ($$$$) {
    my($path, $stat, $option, $message) = @_;
    my($mode);

    $mode = $stat->[ST_MODE] & _IMODE;
    $mode &= ~$option->{mode_clear};
    $mode |=  $option->{mode_set};
    return(0) if ($stat->[ST_MODE] & _IMODE) == $mode;
    return(0) unless $option->{callback}->($path, $message->{mode});
    chmod($mode, $path)
        or dief("cannot chmod(%05o, %s): %s", $mode, $path, $!);
    return(1)
}

#
# ensure proper modification time
#

sub _ensure_mtime ($$$$) {
    my($path, $stat, $option, $message) = @_;

    return(0) if $stat->[ST_MTIME] == $option->{mtime};
    return(0) unless $option->{callback}->($path, $message->{mtime});
    utime($stat->[ST_ATIME], $option->{mtime}, $path)
        or dief("cannot utime(%d, %d, %s): %s",
                $stat->[ST_ATIME], $option->{mtime}, $path, $!);
    return(1);
}

#
# make sure the the file status is what is expected
#

my %stat_ensure_options = (
    user     => { optional => 1, type => SCALAR, regex => qr/^[\w\-]+$/ },
    group    => { optional => 1, type => SCALAR, regex => qr/^[\w\-]+$/ },
    mode     => { optional => 1, type => SCALAR, regex => qr/^[\+\-]?\d+$/ },
    mtime    => { optional => 1, type => SCALAR, regex => qr/^\d+$/ },
    follow   => { optional => 1, type => BOOLEAN },
    callback => { optional => 1, type => CODEREF },
);

sub stat_ensure ($@) {
    my($path, %option, %message, @stat, $changed);

    $path = shift(@_);
    %option = validate(@_, \%stat_ensure_options) if @_;
    _check_user(\%option, \%message);
    _check_group(\%option, \%message);
    _check_mode(\%option, \%message);
    _check_mtime(\%option, \%message);
    $option{callback} ||= sub { return(1) };
    dief("no options given") unless keys(%message);
    if ($option{follow}) {
        @stat = stat($path);
        dief("cannot stat(%s): %s", $path, $!) unless @stat;
    } else {
        @stat = lstat($path);
        dief("cannot lstat(%s): %s", $path, $!) unless @stat;
        # we do not try to change symbolic links
        return(undef) if -l _;
    }
    $changed = 0;
    # first ensure owner
    $changed += _ensure_owner($path, \@stat, \%option, \%message)
        if $message{user} or $message{group};
    # then ensure mode
    $changed += _ensure_mode($path, \@stat, \%option, \%message)
        if $message{mode};
    # finally ensure mtime
    $changed += _ensure_mtime($path, \@stat, \%option, \%message)
        if $message{mtime};
    return($changed);
}

#
# return the file type as a string from stat[ST_MODE]
#

sub stat_type ($) {
    my($mode) = @_;

    unless (@_Mode2Type) {
        eval { $_Mode2Type[S_IFREG()  >> _IBITS] = "plain file" };
        eval { $_Mode2Type[S_IFDIR()  >> _IBITS] = "directory" };
        eval { $_Mode2Type[S_IFIFO()  >> _IBITS] = "pipe" };
        eval { $_Mode2Type[S_IFSOCK() >> _IBITS] = "socket" };
        eval { $_Mode2Type[S_IFBLK()  >> _IBITS] = "block device" };
        eval { $_Mode2Type[S_IFCHR()  >> _IBITS] = "character device" };
        eval { $_Mode2Type[S_IFLNK()  >> _IBITS] = "symlink" };
        eval { $_Mode2Type[S_IFDOOR() >> _IBITS] = "door" };
        eval { $_Mode2Type[S_IFPORT() >> _IBITS] = "event port" };
        eval { $_Mode2Type[S_IFNWK()  >> _IBITS] = "network file" };
        eval { $_Mode2Type[S_IFWHT()  >> _IBITS] = "whiteout" };
    }
    $mode &= S_IFMT;
    $mode >>= _IBITS;
    return($_Mode2Type[$mode] || "unknown");
}

#
# export control
#

sub import : method {
    my($pkg, %exported);

    $pkg = shift(@_);
    grep($exported{$_}++, grep(/^ST?_[A-Z]+$/, keys(%No::Worries::Stat::)));
    grep($exported{$_}++, qw(stat_ensure stat_type));
    export_control(scalar(caller()), $pkg, \%exported, @_);
}

1;

__DATA__

=head1 NAME

No::Worries::Stat - stat() handling without worries

=head1 SYNOPSIS

  use No::Worries::Stat qw(*);

  @stat = stat($path) or die;
  printf("type is %s\n", stat_type($stat[ST_MODE]));
  printf("size is %d\n", $stat[ST_SIZE]);
  printf("user can read\n") if $stat[ST_MODE] & S_IRUSR;

  # make sure "/bin/ls" is owned by root and has the right permissions
  stat_ensure("/bin/ls", user => "root", mode => 0755);
  # make sure "/var/log" is not group or world writable
  stat_ensure("/var/log", mode => "-022");
  # idem but using the S_* constants
  stat_ensure("/var/log", mode => "-" . (S_IWGRP|S_IWOTH));

=head1 DESCRIPTION

This module eases file status handling by providing convenient constants and
functions to get, set and manipulate file status information. All the
functions die() on error.

=head1 CONSTANTS

This module provides the following constants to ease access to stat() fields
(none of them being exported by default):

=over

=item C<ST_DEV>

ID of device containing file

=item C<ST_INO>

inode number

=item C<ST_MODE>

protection

=item C<ST_NLINK>

number of hard links

=item C<ST_UID>

user ID of owner

=item C<ST_GID>

group ID of owner

=item C<ST_RDEV>

device ID (if special file)

=item C<ST_SIZE>

total size, in bytes

=item C<ST_ATIME>

time of last access

=item C<ST_MTIME>

time of last modification

=item C<ST_CTIME>

time of last status change

=item C<ST_BLKSIZE>

blocksize for filesystem I/O

=item C<ST_BLOCKS>

number of 512B blocks allocated

=back

In addition, it also optionally exports all the ":mode" constants from L<Fcntl>.

This way, all the stat() related constants can be imported in a uniform way.

=head1 FUNCTIONS

This module provides the following functions (none of them being
exported by default):

=over

=item stat_type(MODE)

given the file mode (C<ST_MODE> field), return the file type as a string;
possible return values are: "block device", "character device", "directory",
"door", "event port", "network file", "pipe", "plain file", "socket",
"symlink", "unknown" and "whiteout".

=item stat_ensure(PATH[, OPTIONS])

make sure the given path has the expected file "status" (w.r.t. stat()) and
call chown(), chmod() or utime() if needed, returning the number of changes
performed; supported options:

=over

=item * C<user>: expected user name or uid

=item * C<group>: expected group name or gid

=item * C<mode>: expected mode specification (see below)

=item * C<mtime>: expected modification time

=item * C<follow>: follow symbolic links (default is to skip them)

=item * C<callback>: code to be executed before changing something (see below)

=back

=back

The C<mode> option of stat_ensure() can be given:

=over

=item I<NUMBER>

an absolute value like 0755, meaning that mode must be equal to it

=item +I<NUMBER>

a list of bits that must be set, e.g. "+0111" for "executable for all"

=item -I<NUMBER>

a list of bits that must be clear, e.g. "-022" for not writable by group or
other

=back

Note: the number after "+" or "-" will be interpreted as being octal only if
it starts with "0". You should therefore use "+0111" or "+".oct(111) to
enable the executable bits but not "+111" which is the same as "+0157".

The C<callback> option of stat_ensure() will receive the given path and a
string describing what is about to be changed. It must return true to tell
stat_ensure() to indeed perform the changes.

Here is for insatnce how a "noaction" option could be implemented:

  sub noaction ($$) {
      my($path, $change) = @_;
  
      printf("did not change %s of %s\n", $change, $path);
      return(0);
  }
  foreach my $path (@paths) {
      stat_ensure($path, user => "root", mode => 0755, callback => \&noaction);
  }

=head1 SEE ALSO

L<Fcntl>,
L<No::Worries>.

=head1 AUTHOR

Lionel Cons L<http://cern.ch/lionel.cons>

Copyright (C) CERN 2012-2019
