|
- #!/usr/bin/env bash
-
- # Purpose: plain text tar format
- # Limitations: - only suitable for text files, directories, and symlinks
- # - stores only filename, content, and mode
- # - not designed for untrusted input
- #
- # Note: must work with bash version 3.2 (macOS)
-
- # Copyright 2017 Roger Luethi
- #
- # Licensed under the Apache License, Version 2.0 (the "License");
- # you may not use this file except in compliance with the License.
- # You may obtain a copy of the License at
- #
- # http://www.apache.org/licenses/LICENSE-2.0
- #
- # Unless required by applicable law or agreed to in writing, software
- # distributed under the License is distributed on an "AS IS" BASIS,
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- # See the License for the specific language governing permissions and
- # limitations under the License.
-
- set -o errexit -o nounset
-
- # Sanitize environment (for instance, standard sorting of glob matches)
- export LC_ALL=C
-
- path=""
- CMD=""
- ARG_STRING="$*"
-
- #------------------------------------------------------------------------------
- # Not all sed implementations can work on null bytes. In order to make ttar
- # work out of the box on macOS, use Python as a stream editor.
-
- USE_PYTHON=0
-
- PYTHON_CREATE_FILTER=$(cat << 'PCF'
- #!/usr/bin/env python
-
- import re
- import sys
-
- for line in sys.stdin:
- line = re.sub(r'EOF', r'\EOF', line)
- line = re.sub(r'NULLBYTE', r'\NULLBYTE', line)
- line = re.sub('\x00', r'NULLBYTE', line)
- sys.stdout.write(line)
- PCF
- )
-
- PYTHON_EXTRACT_FILTER=$(cat << 'PEF'
- #!/usr/bin/env python
-
- import re
- import sys
-
- for line in sys.stdin:
- line = re.sub(r'(?<!\\)NULLBYTE', '\x00', line)
- line = re.sub(r'\\NULLBYTE', 'NULLBYTE', line)
- line = re.sub(r'([^\\])EOF', r'\1', line)
- line = re.sub(r'\\EOF', 'EOF', line)
- sys.stdout.write(line)
- PEF
- )
-
- function test_environment {
- if [[ "$(echo "a" | sed 's/a/\x0/' | wc -c)" -ne 2 ]]; then
- echo "WARNING sed unable to handle null bytes, using Python (slow)."
- if ! which python >/dev/null; then
- echo "ERROR Python not found. Aborting."
- exit 2
- fi
- USE_PYTHON=1
- fi
- }
-
- #------------------------------------------------------------------------------
-
- function usage {
- bname=$(basename "$0")
- cat << USAGE
- Usage: $bname [-C <DIR>] -c -f <ARCHIVE> <FILE...> (create archive)
- $bname -t -f <ARCHIVE> (list archive contents)
- $bname [-C <DIR>] -x -f <ARCHIVE> (extract archive)
-
- Options:
- -C <DIR> (change directory)
- -v (verbose)
- --recursive-unlink (recursively delete existing directory if path
- collides with file or directory to extract)
-
- Example: Change to sysfs directory, create ttar file from fixtures directory
- $bname -C sysfs -c -f sysfs/fixtures.ttar fixtures/
- USAGE
- exit "$1"
- }
-
- function vecho {
- if [ "${VERBOSE:-}" == "yes" ]; then
- echo >&7 "$@"
- fi
- }
-
- function set_cmd {
- if [ -n "$CMD" ]; then
- echo "ERROR: more than one command given"
- echo
- usage 2
- fi
- CMD=$1
- }
-
- unset VERBOSE
- unset RECURSIVE_UNLINK
-
- while getopts :cf:-:htxvC: opt; do
- case $opt in
- c)
- set_cmd "create"
- ;;
- f)
- ARCHIVE=$OPTARG
- ;;
- h)
- usage 0
- ;;
- t)
- set_cmd "list"
- ;;
- x)
- set_cmd "extract"
- ;;
- v)
- VERBOSE=yes
- exec 7>&1
- ;;
- C)
- CDIR=$OPTARG
- ;;
- -)
- case $OPTARG in
- recursive-unlink)
- RECURSIVE_UNLINK="yes"
- ;;
- *)
- echo -e "Error: invalid option -$OPTARG"
- echo
- usage 1
- ;;
- esac
- ;;
- *)
- echo >&2 "ERROR: invalid option -$OPTARG"
- echo
- usage 1
- ;;
- esac
- done
-
- # Remove processed options from arguments
- shift $(( OPTIND - 1 ));
-
- if [ "${CMD:-}" == "" ]; then
- echo >&2 "ERROR: no command given"
- echo
- usage 1
- elif [ "${ARCHIVE:-}" == "" ]; then
- echo >&2 "ERROR: no archive name given"
- echo
- usage 1
- fi
-
- function list {
- local path=""
- local size=0
- local line_no=0
- local ttar_file=$1
- if [ -n "${2:-}" ]; then
- echo >&2 "ERROR: too many arguments."
- echo
- usage 1
- fi
- if [ ! -e "$ttar_file" ]; then
- echo >&2 "ERROR: file not found ($ttar_file)"
- echo
- usage 1
- fi
- while read -r line; do
- line_no=$(( line_no + 1 ))
- if [ $size -gt 0 ]; then
- size=$(( size - 1 ))
- continue
- fi
- if [[ $line =~ ^Path:\ (.*)$ ]]; then
- path=${BASH_REMATCH[1]}
- elif [[ $line =~ ^Lines:\ (.*)$ ]]; then
- size=${BASH_REMATCH[1]}
- echo "$path"
- elif [[ $line =~ ^Directory:\ (.*)$ ]]; then
- path=${BASH_REMATCH[1]}
- echo "$path/"
- elif [[ $line =~ ^SymlinkTo:\ (.*)$ ]]; then
- echo "$path -> ${BASH_REMATCH[1]}"
- fi
- done < "$ttar_file"
- }
-
- function extract {
- local path=""
- local size=0
- local line_no=0
- local ttar_file=$1
- if [ -n "${2:-}" ]; then
- echo >&2 "ERROR: too many arguments."
- echo
- usage 1
- fi
- if [ ! -e "$ttar_file" ]; then
- echo >&2 "ERROR: file not found ($ttar_file)"
- echo
- usage 1
- fi
- while IFS= read -r line; do
- line_no=$(( line_no + 1 ))
- local eof_without_newline
- if [ "$size" -gt 0 ]; then
- if [[ "$line" =~ [^\\]EOF ]]; then
- # An EOF not preceded by a backslash indicates that the line
- # does not end with a newline
- eof_without_newline=1
- else
- eof_without_newline=0
- fi
- # Replace NULLBYTE with null byte if at beginning of line
- # Replace NULLBYTE with null byte unless preceded by backslash
- # Remove one backslash in front of NULLBYTE (if any)
- # Remove EOF unless preceded by backslash
- # Remove one backslash in front of EOF
- if [ $USE_PYTHON -eq 1 ]; then
- echo -n "$line" | python -c "$PYTHON_EXTRACT_FILTER" >> "$path"
- else
- # The repeated pattern makes up for sed's lack of negative
- # lookbehind assertions (for consecutive null bytes).
- echo -n "$line" | \
- sed -e 's/^NULLBYTE/\x0/g;
- s/\([^\\]\)NULLBYTE/\1\x0/g;
- s/\([^\\]\)NULLBYTE/\1\x0/g;
- s/\\NULLBYTE/NULLBYTE/g;
- s/\([^\\]\)EOF/\1/g;
- s/\\EOF/EOF/g;
- ' >> "$path"
- fi
- if [[ "$eof_without_newline" -eq 0 ]]; then
- echo >> "$path"
- fi
- size=$(( size - 1 ))
- continue
- fi
- if [[ $line =~ ^Path:\ (.*)$ ]]; then
- path=${BASH_REMATCH[1]}
- if [ -L "$path" ]; then
- rm "$path"
- elif [ -d "$path" ]; then
- if [ "${RECURSIVE_UNLINK:-}" == "yes" ]; then
- rm -r "$path"
- else
- # Safe because symlinks to directories are dealt with above
- rmdir "$path"
- fi
- elif [ -e "$path" ]; then
- rm "$path"
- fi
- elif [[ $line =~ ^Lines:\ (.*)$ ]]; then
- size=${BASH_REMATCH[1]}
- # Create file even if it is zero-length.
- touch "$path"
- vecho " $path"
- elif [[ $line =~ ^Mode:\ (.*)$ ]]; then
- mode=${BASH_REMATCH[1]}
- chmod "$mode" "$path"
- vecho "$mode"
- elif [[ $line =~ ^Directory:\ (.*)$ ]]; then
- path=${BASH_REMATCH[1]}
- mkdir -p "$path"
- vecho " $path/"
- elif [[ $line =~ ^SymlinkTo:\ (.*)$ ]]; then
- ln -s "${BASH_REMATCH[1]}" "$path"
- vecho " $path -> ${BASH_REMATCH[1]}"
- elif [[ $line =~ ^# ]]; then
- # Ignore comments between files
- continue
- else
- echo >&2 "ERROR: Unknown keyword on line $line_no: $line"
- exit 1
- fi
- done < "$ttar_file"
- }
-
- function div {
- echo "# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -" \
- "- - - - - -"
- }
-
- function get_mode {
- local mfile=$1
- if [ -z "${STAT_OPTION:-}" ]; then
- if stat -c '%a' "$mfile" >/dev/null 2>&1; then
- # GNU stat
- STAT_OPTION='-c'
- STAT_FORMAT='%a'
- else
- # BSD stat
- STAT_OPTION='-f'
- # Octal output, user/group/other (omit file type, sticky bit)
- STAT_FORMAT='%OLp'
- fi
- fi
- stat "${STAT_OPTION}" "${STAT_FORMAT}" "$mfile"
- }
-
- function _create {
- shopt -s nullglob
- local mode
- local eof_without_newline
- while (( "$#" )); do
- file=$1
- if [ -L "$file" ]; then
- echo "Path: $file"
- symlinkTo=$(readlink "$file")
- echo "SymlinkTo: $symlinkTo"
- vecho " $file -> $symlinkTo"
- div
- elif [ -d "$file" ]; then
- # Strip trailing slash (if there is one)
- file=${file%/}
- echo "Directory: $file"
- mode=$(get_mode "$file")
- echo "Mode: $mode"
- vecho "$mode $file/"
- div
- # Find all files and dirs, including hidden/dot files
- for x in "$file/"{*,.[^.]*}; do
- _create "$x"
- done
- elif [ -f "$file" ]; then
- echo "Path: $file"
- lines=$(wc -l "$file"|awk '{print $1}')
- eof_without_newline=0
- if [[ "$(wc -c "$file"|awk '{print $1}')" -gt 0 ]] && \
- [[ "$(tail -c 1 "$file" | wc -l)" -eq 0 ]]; then
- eof_without_newline=1
- lines=$((lines+1))
- fi
- echo "Lines: $lines"
- # Add backslash in front of EOF
- # Add backslash in front of NULLBYTE
- # Replace null byte with NULLBYTE
- if [ $USE_PYTHON -eq 1 ]; then
- < "$file" python -c "$PYTHON_CREATE_FILTER"
- else
- < "$file" \
- sed 's/EOF/\\EOF/g;
- s/NULLBYTE/\\NULLBYTE/g;
- s/\x0/NULLBYTE/g;
- '
- fi
- if [[ "$eof_without_newline" -eq 1 ]]; then
- # Finish line with EOF to indicate that the original line did
- # not end with a linefeed
- echo "EOF"
- fi
- mode=$(get_mode "$file")
- echo "Mode: $mode"
- vecho "$mode $file"
- div
- else
- echo >&2 "ERROR: file not found ($file in $(pwd))"
- exit 2
- fi
- shift
- done
- }
-
- function create {
- ttar_file=$1
- shift
- if [ -z "${1:-}" ]; then
- echo >&2 "ERROR: missing arguments."
- echo
- usage 1
- fi
- if [ -e "$ttar_file" ]; then
- rm "$ttar_file"
- fi
- exec > "$ttar_file"
- echo "# Archive created by ttar $ARG_STRING"
- _create "$@"
- }
-
- test_environment
-
- if [ -n "${CDIR:-}" ]; then
- if [[ "$ARCHIVE" != /* ]]; then
- # Relative path: preserve the archive's location before changing
- # directory
- ARCHIVE="$(pwd)/$ARCHIVE"
- fi
- cd "$CDIR"
- fi
-
- "$CMD" "$ARCHIVE" "$@"
|