#!/usr/bin/perl
use warnings ;
use strict ;

# IdChange -WRSomsky <somsky@uw.edu> 2017

# === IdChange ================================================================
#
# This script recursivly crawls a directory tree, changing uid/gid's
# according to a supplied id-map-file.  Any uid/gid's not mentioned
# in the map file are left unchanged.
#
# NOTE: Under RHEL6 and RHEL7 based distributions, changing the uid/gid
# of files also clears any SUID/SGID bits under the following conditions:
#
#   directories:
#
#     SUID/SGID bits are left alone during uid/gid changes
#
#   non-dirs:	
#
#     SUID is cleared if the owner changes
#
#     SGID is cleared if the owner or group changes
#                        AND the file is group executable
#
# The net effect is that SUID/SGID directories for file sharing will
# work as expected after the id change, but any SUID/SGID executables
# will # have to have their SUID/SGID bits reset.  Since user-owned
# SUID/SGID executables should be rare and should only be used with
# caution, we do not try to override this behavior.
#
# Usage:
#
#   IdChange [-nvq] [-s] [-z] base-dir [id-map-file]
#
# Arguments & Options:
#
#   base-dir		The base directory which to crawl.  The script
#			will not cross filesystem boundaries.
#
#   id-map-file		File defining the old-to-new uid/gid mappings.
#			Will be found automatically if named 'IdChange.map'
#			in the same directory as the IdChange script.
#
#   -s, --sort		Process the contents of each directory in asciibetical
#			order.  (By default, uses unsorted directory order.)
#
#   -z, --gzip		Compress the output w/ gzip.  (Output can be quite
#			large and is highly compressible.)
#
#   -n, --dryrun	Dry-run.  Go thru the motions, but don't change files.
#
#   -v, --verbose	Report each change made.
#
#   -q, --quiet		Turn off verbose.
#
# Id-Map-File Syntax:
#
#   U<old-uid> <new-uid>	- defines a uid mapping
#   G<old-gid> <new-gid>	- defines a gid mapping
#
#   # Blank lines, and lines beginning w/ '#' are ignored
#

# --- Command Options & Arguments ---------------------------------------------

use File::Basename ;
my $prog = basename $0 ;

use Getopt::Long qw(:config gnu_getopt) ;

my $Usage = <<EOD ;
Usage: $prog [-nvq] [-s] [-z] base-dir [id-map-file]
EOD

my $dryrun  = 0 ;
my $verbose = 0 ;
my $sort    = 0 ;
my $gzip    = 0 ;

GetOptions (
  's|sort'	=> \$sort,   # process sorted
  'z|gzip'	=> \$gzip,   # gzip the output
  'n|dryrun'	=> \$dryrun,
  'v|verbose+'	=> \$verbose,
  'q|quiet'	=> sub { $verbose = 0 },
  ) or die $Usage ;

die $Usage unless @ARGV == 1 or @ARGV == 2 ;

use Cwd qw[abs_path] ;
my $basedir = abs_path $ARGV[0] ;
my $mapfile = $ARGV[1] || "$0.map" ; ## mapfile defaults to "$0.map"

-d $basedir 
  or die "Directory not found: $basedir\n" ;

# --- Utility -----------------------------------------------------------------

sub printable
  {
  $_ = shift ;
  s/[[:^graph:]\\]/sprintf("\\%02X",unpack("c",$&))/eg ;
  return $_ ;
  }

sub comma
  {
  scalar reverse join ',', unpack '(A3)*', scalar reverse ($_[0]) ;
  }

sub HMS
  {
  my $s = int (shift) || 0 ;
  my $m = int ($s/60) ; $s %= 60 ;
  my $h = int ($m/60) ; $m %= 60 ;
  return sprintf "%d:%02d:%02d", $h, $m, $s ;
  }

sub timestamp
  {
  use POSIX ;
  my $time = shift || time ;
  strftime "%F %T", localtime $time ;
  }

# --- Setup Output FileHandle -------------------------------------------------

my $out ;

if ($gzip)
  {
  open $out, '|/bin/gzip'
    or die "Cannot open gzip pipe: $!\n" ;
  ## ---
  ## use IO::Compress::Gzip qw[ $GzipError ] ;
  ## $out = new IO::Compress::Gzip "-",
  ##   or die "Cannot open gzip filter: $GzipError\n" ;
  }
else
  {
  open $out, '>&', STDOUT
    or die "Cannot create handle to STDOUT: $!\n" ;
  }

# --- Load UID/GID Maps -------------------------------------------------------

my %uid_map ;
my %gid_map ;

open MAP, '<', $mapfile
  or die "Open error: $!: $mapfile\n" ;

while (<MAP>)
  {
  chomp ;
  next if /^\s*$/ or /^\s*#/ ;
  if (/^([UG])(\d+)\s+(\d+)\s*$/)
    {
    $uid_map{$2} = $3 if $1 eq 'U' ;
    $gid_map{$2} = $3 if $1 eq 'G' ;
    }
  else
    {
    die "Bad UID/GID map line:\nl.$.: $_\n" ;
    }
  }

print $out "## UID Map: @{[scalar keys %uid_map]} entries\n" if $verbose ;
print $out "## GID Map: @{[scalar keys %gid_map]} entries\n" if $verbose ;

# --- Walk File Tree ----------------------------------------------------------

my $count = 0 ;
my $changes = 0 ;

use File::Find ;
use File::stat ;

sub file_handler
  {
  $count++ ;
  my $path = $File::Find::name  ;
  my $stat = lstat $path
    or return print $out "#! Stat failed: $!: @{[printable $path]}\n" ;

  my     $type_ok = 0 ;
  my     $type = $stat->mode >> 12 ;
  my     $perm  = $stat->mode & 07777 ;

  if    ($type == 010) { $type = 'f' ; $type_ok = 1 ; }
  elsif ($type == 012) { $type = 'l' ; $type_ok = 1 ; }
  elsif ($type == 004) { $type = 'd' ; $type_ok = 1 ; }
  elsif ($type == 014) { $type = 's' ; $type_ok = 1 ; }
  elsif ($type == 001) { $type = 'p' ; $type_ok = 1 ; }
  elsif ($type == 006) { $type = 'b' ; }
  elsif ($type == 002) { $type = 'c' ; }
  else                 { $type = '?' ; }

  $type_ok
    or return print $out "#! Skipping $type: @{[printable $path]}\n" ;

  my $uid     = $stat->uid ;
  my $gid     = $stat->gid ;
  my $new_uid = $uid_map{$uid} ; ## // $uid ;
  my $new_gid = $gid_map{$gid} ; ## // $gid ;
     $new_uid = $uid if not defined $new_uid ;
     $new_gid = $gid if not defined $new_gid ;

  if ($new_uid != $uid or $new_gid != $gid)
    {
    $changes++ ;
    if ($verbose)
      {
      printf $out "* %10s %10s %s\n",
        ($new_uid == $uid ? '-' : $new_uid),
        ($new_gid == $gid ? '-' : $new_gid),
	printable $path ;
      }
    if (!$dryrun)
      {
      if ($type eq 'l') # symlink
        {
	# Change the uid/gid of the link itself
	# Use POSIX lchown on newer systems ???
	0 == system "/bin/chown", "-h", "$new_uid.$new_gid", $path
	  or print $out "#! System-chown failed: $!: @{[printable $path]}\n" ;
	}
      else
        {
	chown $new_uid, $new_gid, $path
	  or print $out "#! Chown failed: $!: @{[printable $path]}\n" ;
	}
      }
    }
  }

my $start = time ;

my $find_opts = { wanted => \&file_handler, no_chdir => 1 } ;
$find_opts->{preprocess} = sub {sort @_} if $sort ;
find ( $find_opts, $basedir) ;

my $end = time ;

printf $out "## %s DONE %s in %s / %s (%s/s)\n", 
  timestamp,
  comma ($changes),
  comma ($count),
  HMS ($end - $start),
  $end <= $start ? "-" : comma int ($count/($end - $start)) ;

close $out ;

__END__ # =====================================================================
