#!/usr/bin/perl -w
######################################################################
#
# $Id: xshar,v 1.8 2012/01/07 08:01:24 mavrik Exp $
#
######################################################################
#
# Copyright 2003-2012 The WebJob Project, All Rights Reserved.
#
######################################################################
#
# Purpose: Create an extended shell archive of one or more files.
#
######################################################################

use strict;
use File::Basename;
use Getopt::Std;

BEGIN
{
  ####################################################################
  #
  # The Properties hash is essentially private. Those parts of the
  # program that wish to access or modify the data in this hash need
  # to call GetProperties() to obtain a reference.
  #
  ####################################################################

  my (%hProperties);

  sub GetProperties
  {
    return \%hProperties;
  }
}

######################################################################
#
# Main Routine
#
######################################################################

  ####################################################################
  #
  # Punch in and go to work.
  #
  ####################################################################

  my ($phProperties);

  $phProperties = GetProperties();

  $$phProperties{'Program'} = basename(__FILE__);

  ####################################################################
  #
  # Get Options.
  #
  ####################################################################

  my (%hOptions);

  if (!getopts('u:', \%hOptions))
  {
    Usage($$phProperties{'Program'});
  }

  ####################################################################
  #
  # A umask, '-u', is optional.
  #
  ####################################################################

  $$phProperties{'Umask'} = (exists($hOptions{'u'})) ? $hOptions{'u'} : "022";

  ####################################################################
  #
  # If there isn't at least one argument left, it's an error.
  #
  ####################################################################

  if (scalar(@ARGV) < 1)
  {
    Usage($$phProperties{'Program'});
  }

  ####################################################################
  #
  # Build the shell archive, and write it to stdout.
  #
  ####################################################################

  my ($sError);

  foreach my $sFile (@ARGV)
  {
    if (!-f $sFile || !-r _)
    {
      print STDERR "$$phProperties{'Program'}: Error='File ($sFile) must exist, be a regular file, and be readable.'\n";
      exit(2);
    }
    push(@{$$phProperties{'Files'}}, $sFile);
  }

  if (!CreateArchive($phProperties, \$sError))
  {
    print STDERR "$$phProperties{'Program'}: Error='$sError'\n";
    exit(2);
  }

  ####################################################################
  #
  # Cleanup and go home.
  #
  ####################################################################

  1;

######################################################################
#
# CreateArchive
#
######################################################################

sub CreateArchive
{
  my ($phProperties, $psError) = @_;

  ####################################################################
  #
  # Prepare various lists and token replacement expressions.
  #
  ####################################################################

  my @aDeploySequence;
  my @aContents;
  my @aDirs;
  my @aFiles;
  my @aLsSharSequence;
  my @aNormalTokenReplacements;
  my @aNumberTokenReplacements;
  my %hDirs;
  my $sDeploySequence;
  my $sListCount;
  my $sLsSharSequence;
  my $sRemoveSequence;
  my $sTokenReplacements;

  foreach my $sOriginalFile (@{$$phProperties{'Files'}})
  {
    my $sFile = $sOriginalFile; # A copy is required, here, to preserve the original filename.
    $sFile =~ s/^\/+//;
    my $sDir = dirname($sFile);
    if (defined($sDir) && $sDir ne ".")
    {
      my $sPath = "";
      foreach my $sDir (split(/\//, $sDir))
      {
        $sPath .= ($sPath eq "") ? $sDir : "/" . $sDir;
        if (!$hDirs{$sPath})
        {
          $sListCount++;
          push(@aNumberTokenReplacements, "s!%" . $sListCount . "!$sPath!g;");
          push(@aLsSharSequence, $sListCount);
          push(@aContents, $sPath);
          push(@aDirs, $sPath);
          $hDirs{$sPath} = 1;
        }
      }
    }
    $sListCount++;
    push(@aNumberTokenReplacements, "s!%" . $sListCount . "!$sFile!g;");
    push(@aDeploySequence, $sListCount);
    push(@aLsSharSequence, $sListCount);
    push(@aContents, $sFile);
    push(@aFiles, $sFile);
  }

  $sDeploySequence = " " . join(" ", @aDeploySequence);
  $sDeploySequence =~ s/ / %/g;

  $sRemoveSequence = " " . join(" ", reverse(@aLsSharSequence));
  $sRemoveSequence =~ s/ / %/g;

  $sLsSharSequence = " " . join(" ", @aLsSharSequence);
  $sLsSharSequence =~ s/ / %/g;

  push(@aNormalTokenReplacements, ("s!%contents!" . join(" ", @aContents) . "!g;"));
  push(@aNormalTokenReplacements, ("s!%dirs!" . join(" ", @aDirs) . "!g;"));
  push(@aNormalTokenReplacements, ("s!%files!" . join(" ", @aFiles) . "!g;"));
  push(@aNormalTokenReplacements, "s!%cmd!\${PROGRAM}!g;");
  push(@aNormalTokenReplacements, "s!%cwd!\${MY_CWD}!g;");
  push(@aNormalTokenReplacements, "s!%owd!\${MY_OWD}!g;");
  $sTokenReplacements = join(" ", "s!%%!%esc!g;", @aNormalTokenReplacements, reverse(@aNumberTokenReplacements), "s!%esc!%!g;");

  ####################################################################
  #
  # Generate the archive.
  #
  ####################################################################

  my $sFileHandle = \*STDOUT;

  print $sFileHandle <<'EOF';
#!/bin/sh
######################################################################
#
# This is an extended shell archive built by xshar.
#
######################################################################

IFS=' 	
'

PATH=/sbin:/usr/sbin:/usr/local/sbin:/bin:/usr/bin:/usr/local/bin

PROGRAM=`basename $0`

XER_OK=0
XER_Usage=1
XER_BootStrap=2
XER_ProcessArguments=3
XER_DoConfigure=4
XER_DeployFiles=5
XER_ListArchive=6
XER_RemoveContents=7
XER_RevertContents=8
XER_RunCommand=9

EOF

  print $sFileHandle <<EOF;
SharBootStrap()
{
  SHAR_DEPLOY_SEQUENCE="$sDeploySequence"
  SHAR_LSSHAR_SEQUENCE="$sLsSharSequence"
  SHAR_MKBACKUP=0
  SHAR_REMOVE_SEQUENCE="$sRemoveSequence"
  SHAR_REVERT_SEQUENCE=\${SHAR_DEPLOY_SEQUENCE}
  SHAR_UMASK=$$phProperties{'Umask'}
}

SharDeployContents()
{
EOF

  print $sFileHandle <<'EOF';
  MY_MKBACKUP=$1

  MY_ERRORS=0
EOF

  foreach my $sOriginalFile (@{$$phProperties{'Files'}})
  {
    my $sFile = $sOriginalFile; # A copy is required, here, to preserve the original filename.
    $sFile =~ s/^\/+//;
    my $sName = basename($sFile);

    print $sFileHandle <<EOF;
  MY_FILE=$sFile
  MY_DIR=`dirname \${MY_FILE}`
  if [ ! -d \${MY_DIR} ] ; then
    echo "deploy - \${MY_DIR}"
    mkdir -p \${MY_DIR}
  fi
  if [ \${MY_MKBACKUP} -eq 1 -a -f \${MY_FILE} ] ; then
    echo "deploy - \${MY_FILE} --> \${MY_FILE}.xsb"
    mv \${MY_FILE} \${MY_FILE}.xsb
  fi
  echo "deploy - \${MY_FILE}"
  sed 's/^X//' > \${MY_FILE} << 'END-of-$sName'
EOF

    if (!open(FH, "< $sOriginalFile"))
    {
      $$psError = "File ($sOriginalFile) could not be opened ($!).";
      return undef;
    }
    while (my $sLine = <FH>)
    {
      if ($sLine =~ /[\x00-\x08\x0b\x0c\x0e-\x19\x7f-\xff]/)
      {
        $$psError = "File ($sOriginalFile) contains illegal characters and can not be archived.";
        return undef;
      }
      $sLine =~ s/^/X/;
      print $sFileHandle $sLine;
    }
    close(FH);
    print $sFileHandle <<EOF;
END-of-$sName
  if [ \$? -ne 0 ] ; then
    MY_ERRORS=1
  fi

EOF
  }

  print $sFileHandle <<'EOF';
  if [ ${MY_ERRORS} -eq 1 ] ; then
    ERROR="Failed to extract one or more files."
    return 1
  fi
  return 0
}

SharDoConfigure()
{
  MY_UMASK=$1
  MY_CDTO=${2-.}

  MY_FINAL_UMASK=`echo ${MY_UMASK} | grep '^0[0-7]\{0,3\}$' 2> /dev/null`
  if [ -z "${MY_FINAL_UMASK}" ] ; then
    ERROR="Invalid umask (${MY_UMASK}). Operation aborted."
    return 1
  else
    umask ${MY_FINAL_UMASK}
  fi

  if [ -n "${MY_CDTO}" ] ; then
    if [ ! -d ${MY_CDTO} ] ; then
      ERROR="The specified path (${MY_CDTO}) is not a directory or does not exist. Operation aborted."
      return 1
    fi
    cd ${MY_CDTO} > /dev/null 2>&1
    if [ $? -ne 0 ] ; then
      ERROR="Unable to change to the specified directory (${MY_CDTO}). Operation aborted."
      return 1
    fi
  fi

  return 0
}

SharDoTokenReplacements()
{
EOF

    print $sFileHandle <<EOF;
  MY_RESULT=`echo "\$1" | sed "$sTokenReplacements" 2> /dev/null`
EOF

    print $sFileHandle <<'EOF';
  echo ${MY_RESULT}
}

SharListContents()
{
  MY_LSSHAR_SEQUENCE=$1

  MY_COUNT=1
  for MY_NAME in `SharDoTokenReplacements "${MY_LSSHAR_SEQUENCE}"` ; do
    echo "lsshar - ${MY_NAME} (%${MY_COUNT})"
    MY_COUNT=`expr ${MY_COUNT} + 1`
  done
}

SharProcessArguments()
{
  if [ $# -lt 1 ] ; then
    SharUsage
  fi
  case "$1" in
  -d|--deploy)
    SHAR_MODE=deploy
    MY_OPTIONS="bC:u:"
    ;;
  -l|--lsshar)
    SHAR_MODE=lsshar
    MY_OPTIONS="C:u:"
    ;;
  -r|--remove)
    SHAR_MODE=remove
    MY_OPTIONS="C:u:"
    ;;
  -R|--revert)
    SHAR_MODE=revert
    MY_OPTIONS="C:u:"
    ;;
  *)
    SharUsage;
    ;;
  esac
  shift
  MY_SPENT_ARGUMENTS=0
  while getopts "${MY_OPTIONS}" OPTION ; do
    case "${OPTION}" in
    b)
      SHAR_MKBACKUP=1
      MY_SPENT_ARGUMENTS=`expr ${MY_SPENT_ARGUMENTS} + 1`
      ;;
    C)
      SHAR_CDTO="${OPTARG}"
      MY_SPENT_ARGUMENTS=`expr ${MY_SPENT_ARGUMENTS} + 2`
      ;;
    u)
      SHAR_UMASK="${OPTARG}"
      MY_SPENT_ARGUMENTS=`expr ${MY_SPENT_ARGUMENTS} + 2`
      ;;
    *)
      SharUsage
      ;;
    esac
  done
  if [ ${OPTIND} -le $# ] ; then
    shift ${MY_SPENT_ARGUMENTS}
    if [ X"$1" != X"--" ] ; then
      SharUsage
    else
      shift
      SHAR_RUNCMD="$*"
    fi
  fi
  return 0
}

SharRemoveContents()
{
  MY_REMOVE_SEQUENCE=$1

  for MY_NAME in `SharDoTokenReplacements "${MY_REMOVE_SEQUENCE}"` ; do
    if [ -d ${MY_NAME} ] ; then
      MY_COUNT=`find ${MY_NAME} | wc -l | awk '{print $1}'`
      if [ -n "${MY_COUNT}" -a ${MY_COUNT} -eq 1 ] ; then
        echo "remove - ${MY_NAME}"
        rmdir ${MY_NAME}
      else
        echo "remove - ${MY_NAME} (not empty)"
      fi
    elif [ -f ${MY_NAME} ] ; then
      echo "remove - ${MY_NAME}"
      rm -f ${MY_NAME}
    else
      echo "remove - ${MY_NAME} (not found)"
    fi
  done
  return 0
}

SharRevertContents()
{
  MY_REVERT_SEQUENCE=$1

  MY_UNREVERTED=0
  for MY_NAME in `SharDoTokenReplacements "${MY_REVERT_SEQUENCE}"` ; do
    if [ -f ${MY_NAME}.xsb ] ; then
      echo "revert - ${MY_NAME}.xsb --> ${MY_NAME}"
      mv ${MY_NAME}.xsb ${MY_NAME}
      if [ $? -ne 0 ] ; then
        MY_UNREVERTED=`expr ${MY_UNREVERTED} + 1`
      fi
    else
      echo "remove - ${MY_NAME}.xsb (not found)"
      MY_UNREVERTED=`expr ${MY_UNREVERTED} + 1`
    fi
  done
  if [ ${MY_UNREVERTED} -gt 0 ] ; then
    ERROR="One or more files were missing or could not be reverted."
    return 1
  fi
  return 0
}

SharRunCommand()
{
  MY_OWD=$1 ; shift
  MY_CWD=$1 ; shift
  MY_RUNCMD="$*"

EOF

  print $sFileHandle <<EOF;
  MY_RUNCMD=`SharDoTokenReplacements "\$*"`
EOF

  print $sFileHandle <<'EOF';
  echo "runcmd - ${MY_RUNCMD}"
  eval ${MY_RUNCMD}
  MY_STATUS=$?
  echo "status - ${MY_STATUS}"
  return 0
}

SharUsage()
{
  echo 1>&2
  echo "Usage: ${PROGRAM} {-d|--deploy} [options] [-- command [options]]" 1>&2
  echo "       ${PROGRAM} {-l|--lsshar} [options] [-- command [options]]" 1>&2
  echo "       ${PROGRAM} {-r|--remove} [options] [-- command [options]]" 1>&2
  echo "       ${PROGRAM} {-R|--revert} [options] [-- command [options]]" 1>&2
  echo 1>&2
  exit ${XER_Usage}
}

SharMain()
{
  SharBootStrap
  if [ $? -ne 0 ] ; then
    echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_BootStrap}
  fi
  SharProcessArguments $*
  if [ $? -ne 0 ] ; then
    echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_ProcessArguments}
  fi
  SHAR_OWD=`pwd`
  SharDoConfigure "${SHAR_UMASK}" "${SHAR_CDTO}"
  if [ $? -ne 0 ] ; then
    echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_DoConfigure}
  fi
  SHAR_CWD=`pwd`
  case "${SHAR_MODE}" in
  deploy)
    SharDeployContents "${SHAR_MKBACKUP}"
    if [ $? -ne 0 ] ; then
      echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_DeployFiles}
    fi
    ;;
  lsshar)
    SharListContents "${SHAR_LSSHAR_SEQUENCE}"
    if [ $? -ne 0 ] ; then
      echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_ListArchive}
    fi
    ;;
  remove)
    SharRemoveContents "${SHAR_REMOVE_SEQUENCE}"
    if [ $? -ne 0 ] ; then
      echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_RemoveContents}
    fi
    ;;
  revert)
    SharRevertContents "${SHAR_REVERT_SEQUENCE}"
    if [ $? -ne 0 ] ; then
      echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_RevertContents}
    fi
    ;;
  *)
    ;;
  esac
  if [ -n "${SHAR_RUNCMD}" ] ; then
    SharRunCommand "${SHAR_OWD}" "${SHAR_CWD}" "${SHAR_RUNCMD}"
    if [ $? -ne 0 ] ; then
      echo "${PROGRAM}: Error='${ERROR-?}'" 1>&2 ; return ${XER_RunCommand}
    fi
  fi
  return ${XER_OK}
}

SharMain $* ; exit $?
EOF

  1;
}


######################################################################
#
# Usage
#
######################################################################

sub Usage
{
  my ($sProgram) = @_;
  print STDERR "\n";
  print STDERR "Usage: $sProgram [-u umask] file [file ...]\n";
  print STDERR "\n";
  exit(1);
}


=pod

=head1 NAME

xshar - Create an extended shell archive of one or more files.

=head1 SYNOPSIS

B<xshar> B<[-u umask]> B<file [file ...]>

=head1 DESCRIPTION

This utility creates an extended shell archive from one or more input
files.  The archive is written to stdout as a sh(1) script.  Only
regular, text files may be placed in the archive.  The resulting
script will attempt to create intermediate directories on extraction.

Archives, when executed with no arguments, will print the following
usage:

  <script> {-d|--deploy} [options] [-- command [options]]
           {-l|--lsshar} [options] [-- command [options]]
           {-r|--remove} [options] [-- command [options]]
           {-R|--revert} [options] [-- command [options]]

Details about the archive's various execution modes and options can be
found in the following sections.

=head1 MODES OF OPERATION (SHELL ARCHIVE)

Each of the shell archive's execution modes is described below.

=over 4

=item B<-d|--deploy>

Deploy the contents of the archive (creating intermediate directories
as needed), and conditionally execute a follow-up command.

=item B<-l|--lsshar>

List the contents of the archive, and conditionally execute a
follow-up command.

=item B<-r|--remove>

Remove the contents of the archive (including empty directories), and
conditionally execute a follow-up command.

=item B<-R|--revert>

Revert the contents of the archive, and conditionally execute a
follow-up command.  Note that only files with a corresponding backup
file (<file>.xsb) will be reverted.

=back

=head1 OPTIONS (XSHAR)

=over 4

=item B<-u umask>

Specifies a umask value to embed (as a default value) in the shell
archive.  If not specified, a value of 022 is used.

=back

=head1 OPTIONS (SHELL ARCHIVE)

Each of the shell archive's execution modes will accept the following
options:

=over 4

=item B<-C dir>

Change to the specified directory before performing any work.  If the
directory does not exist, the script will abort.

=item B<-u umask>

Set the run-time umask to the value specified.

=back

Additionally, B<--deploy> mode will accept the following option:

=over 4

=item B<-b>

Create backup files if the files being deployed already exist.  Backup
files will have an extension of '.xsb'.

=back

=head1 FOLLOW-UP COMMAND (SHELL ARCHIVE)

The tokens described below may be used as place holders in the
follow-up command.  Before the command is executed, these tokens are
expanded to their current values.  The character '%' may be used to
produce a literal token.  For example, '%%1' would be expanded to
'%1'.

=over 4

=item B<%%>

Escapes a single '%' character.

=item B<%1-%N>

Expands to the name of a file or directory contained in the archive.
The order that files are added to the archive determines their token
value.

=item B<%contents>

Expands to a list of all files and directories in the archive.

=item B<%cmd>

Expands to the basename of the executing script.

=item B<%cwd>

Expands to the current working directory.  In general, this will
differ from B<%owd> when the B<-C> option is used to change to
directories.

=item B<%dirs>

Expands to a list of all directories in the archive.

=item B<%files>

Expands to a list of all files in the archive.

=item B<%owd>

Expands to the original working directory.

=back

=head1 EXAMPLES (SHELL ARCHIVE)

=head2 Example 1. List the contents of a shell archive

This example demonstrates how to list the contents of an archive
called 'out.sh'.

  $ sh out.sh --lsshar

or (in short form)

  $ sh out.sh -l

=head2 Example 2. Deploy the contents of a shell archive

This example demonstrates how to deploy the contents of an archive
called 'out.sh' to /usr/local/etc.

  $ sh out.sh --deploy -C /usr/local/etc

or (in short form)

  $ sh out.sh -d -C /usr/local/etc

Note: All files in an archive are relative.  Therefore, if the B<-C>
option is not used, the files will be extracted to the current working
directory.

Note: The specified directory must exist, or the script will abort.

=head2 Example 3. Remove the contents of a shell archive

This example demonstrates how to remove the contents of an archive
called 'out.sh' from /usr/local/etc.

  $ sh out.sh --remove -C /usr/local/etc

or (in short form)

  $ sh out.sh -r -C /usr/local/etc

Note: All files in an archive are relative.  Therefore, if the B<-C>
option is not used, the files will be removed from the current working
directory.

Note: The script will attempt to remove any directories that it may
have created.  These operations will fail if the directories are not
empty.

=head2 Example 4. Use tokens in a follow-up command

This example demonstrates how to use tokens in a follow-up command to
perform inline editing of a file (upload.cfg) once it has been
deployed.  First, list the contents of the archive (out.sh):

  $ sh out.sh -l
  lsshar - config (%1)
  lsshar - config/import.cfg (%2)
  lsshar - config/upload.cfg (%3)

Note the token value (%3) that correspnds to the file upload.cfg.

Next, deploy the contents of the archive to the current working
directory using a follow-up command that will remove any lines in
upload.cfg that begin with the string 'Import='.

  $ sh out.sh -d -- 'perl -n -i -e "print unless (/^Import=/);" %3'
  deploy - config
  deploy - config/import.cfg
  deploy - config/upload.cfg
  runcmd - perl -n -i -e "print unless (/^Import=/);" config/upload.cfg
  status - 0

=head2 Example 5. Deploy a shell archive containing a uuencoded binary

This example demonstrates how to deploy a shell archive containing a
uuencoded file and set the permissions on the resulting binary.
Assume that the archive in question was built using B<Example 3> from
the B<EXAMPLES (XSHAR)> section.

  $ sh webjob.sh -d -- 'uudecode %1 && chmod 755 `basename %1 .uu`'

To remove the original uuencoded file when done, substitute the
command shown above with the one given here:

  '{ uudecode %1 && chmod 755 `basename %1 .uu` ; rm -f %1 ; }'

=head1 EXAMPLES (XSHAR)

=head2 Example 1. Create a shell archive of several '.cfg' files

This example demonstrates how to create an archive of all '.cfg' files
in the current directory:

  $ xshar *.cfg > out.sh

=head2 Example 2. Create a shell archive of a given directory

This example demonstrates how to create an archive of all files in a
given directory:

  $ xshar `find <dir> -type f` > out.sh

=head2 Example 3. Create a shell archive containing a uuencoded binary

This example demonstrates how to create a shell archive containing a
uuencoded binary.

  $ uuencode webjob > webjob.uu
  $ xshar webjob.uu > webjob.sh

=head1 AUTHOR

Klayton Monroe

=head1 HISTORY

This utility started out as a script that could create custom shell
archives to hold B<WebJob> config files.  The shell archive format is
a good fit for B<WebJob> because it is executable and can hold
multiple files.  Therefore, clients needing to update their local
configuration can do so in a single operation simply by running a job
that downloads and deploys their personalized shell archive.

Over time, the custom bits that made this utility application-specific
were refactored and generalized away.  Also, the code was heavily
influenced by PaD, which predates the use of shell archives within the
project.  In particular, B<xshar> adopted PaD's delivery command
capability -- being able to run delivery/follow-up commands has proven
to be very useful in B<WebJob> deployments.

This utility first appeared in B<WebJob> 1.8.0.

=head1 SEE ALSO

sh(1), shar(1)

=head1 LICENSE

All documentation and code are distributed under same terms and
conditions as B<WebJob>.

=cut

