blob: b0171a12b59f32f630a330ce0584eda70e3495a1 [file] [log] [blame]
Matthias Andreas Benkard832a54e2019-01-29 09:27:38 +01001#!/usr/bin/env bash
2
3# Purpose: plain text tar format
4# Limitations: - only suitable for text files, directories, and symlinks
5# - stores only filename, content, and mode
6# - not designed for untrusted input
7#
8# Note: must work with bash version 3.2 (macOS)
9
10# Copyright 2017 Roger Luethi
11#
12# Licensed under the Apache License, Version 2.0 (the "License");
13# you may not use this file except in compliance with the License.
14# You may obtain a copy of the License at
15#
16# http://www.apache.org/licenses/LICENSE-2.0
17#
18# Unless required by applicable law or agreed to in writing, software
19# distributed under the License is distributed on an "AS IS" BASIS,
20# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21# See the License for the specific language governing permissions and
22# limitations under the License.
23
24set -o errexit -o nounset
25
26# Sanitize environment (for instance, standard sorting of glob matches)
27export LC_ALL=C
28
29path=""
30CMD=""
31ARG_STRING="$*"
32
33#------------------------------------------------------------------------------
34# Not all sed implementations can work on null bytes. In order to make ttar
35# work out of the box on macOS, use Python as a stream editor.
36
37USE_PYTHON=0
38
39PYTHON_CREATE_FILTER=$(cat << 'PCF'
40#!/usr/bin/env python
41
42import re
43import sys
44
45for line in sys.stdin:
46 line = re.sub(r'EOF', r'\EOF', line)
47 line = re.sub(r'NULLBYTE', r'\NULLBYTE', line)
48 line = re.sub('\x00', r'NULLBYTE', line)
49 sys.stdout.write(line)
50PCF
51)
52
53PYTHON_EXTRACT_FILTER=$(cat << 'PEF'
54#!/usr/bin/env python
55
56import re
57import sys
58
59for line in sys.stdin:
60 line = re.sub(r'(?<!\\)NULLBYTE', '\x00', line)
61 line = re.sub(r'\\NULLBYTE', 'NULLBYTE', line)
62 line = re.sub(r'([^\\])EOF', r'\1', line)
63 line = re.sub(r'\\EOF', 'EOF', line)
64 sys.stdout.write(line)
65PEF
66)
67
68function test_environment {
69 if [[ "$(echo "a" | sed 's/a/\x0/' | wc -c)" -ne 2 ]]; then
70 echo "WARNING sed unable to handle null bytes, using Python (slow)."
71 if ! which python >/dev/null; then
72 echo "ERROR Python not found. Aborting."
73 exit 2
74 fi
75 USE_PYTHON=1
76 fi
77}
78
79#------------------------------------------------------------------------------
80
81function usage {
82 bname=$(basename "$0")
83 cat << USAGE
84Usage: $bname [-C <DIR>] -c -f <ARCHIVE> <FILE...> (create archive)
85 $bname -t -f <ARCHIVE> (list archive contents)
86 $bname [-C <DIR>] -x -f <ARCHIVE> (extract archive)
87
88Options:
89 -C <DIR> (change directory)
90 -v (verbose)
91
92Example: Change to sysfs directory, create ttar file from fixtures directory
93 $bname -C sysfs -c -f sysfs/fixtures.ttar fixtures/
94USAGE
95exit "$1"
96}
97
98function vecho {
99 if [ "${VERBOSE:-}" == "yes" ]; then
100 echo >&7 "$@"
101 fi
102}
103
104function set_cmd {
105 if [ -n "$CMD" ]; then
106 echo "ERROR: more than one command given"
107 echo
108 usage 2
109 fi
110 CMD=$1
111}
112
113unset VERBOSE
114
115while getopts :cf:htxvC: opt; do
116 case $opt in
117 c)
118 set_cmd "create"
119 ;;
120 f)
121 ARCHIVE=$OPTARG
122 ;;
123 h)
124 usage 0
125 ;;
126 t)
127 set_cmd "list"
128 ;;
129 x)
130 set_cmd "extract"
131 ;;
132 v)
133 VERBOSE=yes
134 exec 7>&1
135 ;;
136 C)
137 CDIR=$OPTARG
138 ;;
139 *)
140 echo >&2 "ERROR: invalid option -$OPTARG"
141 echo
142 usage 1
143 ;;
144 esac
145done
146
147# Remove processed options from arguments
148shift $(( OPTIND - 1 ));
149
150if [ "${CMD:-}" == "" ]; then
151 echo >&2 "ERROR: no command given"
152 echo
153 usage 1
154elif [ "${ARCHIVE:-}" == "" ]; then
155 echo >&2 "ERROR: no archive name given"
156 echo
157 usage 1
158fi
159
160function list {
161 local path=""
162 local size=0
163 local line_no=0
164 local ttar_file=$1
165 if [ -n "${2:-}" ]; then
166 echo >&2 "ERROR: too many arguments."
167 echo
168 usage 1
169 fi
170 if [ ! -e "$ttar_file" ]; then
171 echo >&2 "ERROR: file not found ($ttar_file)"
172 echo
173 usage 1
174 fi
175 while read -r line; do
176 line_no=$(( line_no + 1 ))
177 if [ $size -gt 0 ]; then
178 size=$(( size - 1 ))
179 continue
180 fi
181 if [[ $line =~ ^Path:\ (.*)$ ]]; then
182 path=${BASH_REMATCH[1]}
183 elif [[ $line =~ ^Lines:\ (.*)$ ]]; then
184 size=${BASH_REMATCH[1]}
185 echo "$path"
186 elif [[ $line =~ ^Directory:\ (.*)$ ]]; then
187 path=${BASH_REMATCH[1]}
188 echo "$path/"
189 elif [[ $line =~ ^SymlinkTo:\ (.*)$ ]]; then
190 echo "$path -> ${BASH_REMATCH[1]}"
191 fi
192 done < "$ttar_file"
193}
194
195function extract {
196 local path=""
197 local size=0
198 local line_no=0
199 local ttar_file=$1
200 if [ -n "${2:-}" ]; then
201 echo >&2 "ERROR: too many arguments."
202 echo
203 usage 1
204 fi
205 if [ ! -e "$ttar_file" ]; then
206 echo >&2 "ERROR: file not found ($ttar_file)"
207 echo
208 usage 1
209 fi
210 while IFS= read -r line; do
211 line_no=$(( line_no + 1 ))
212 local eof_without_newline
213 if [ "$size" -gt 0 ]; then
214 if [[ "$line" =~ [^\\]EOF ]]; then
215 # An EOF not preceeded by a backslash indicates that the line
216 # does not end with a newline
217 eof_without_newline=1
218 else
219 eof_without_newline=0
220 fi
221 # Replace NULLBYTE with null byte if at beginning of line
222 # Replace NULLBYTE with null byte unless preceeded by backslash
223 # Remove one backslash in front of NULLBYTE (if any)
224 # Remove EOF unless preceeded by backslash
225 # Remove one backslash in front of EOF
226 if [ $USE_PYTHON -eq 1 ]; then
227 echo -n "$line" | python -c "$PYTHON_EXTRACT_FILTER" >> "$path"
228 else
229 # The repeated pattern makes up for sed's lack of negative
230 # lookbehind assertions (for consecutive null bytes).
231 echo -n "$line" | \
232 sed -e 's/^NULLBYTE/\x0/g;
233 s/\([^\\]\)NULLBYTE/\1\x0/g;
234 s/\([^\\]\)NULLBYTE/\1\x0/g;
235 s/\\NULLBYTE/NULLBYTE/g;
236 s/\([^\\]\)EOF/\1/g;
237 s/\\EOF/EOF/g;
238 ' >> "$path"
239 fi
240 if [[ "$eof_without_newline" -eq 0 ]]; then
241 echo >> "$path"
242 fi
243 size=$(( size - 1 ))
244 continue
245 fi
246 if [[ $line =~ ^Path:\ (.*)$ ]]; then
247 path=${BASH_REMATCH[1]}
248 if [ -e "$path" ] || [ -L "$path" ]; then
249 rm "$path"
250 fi
251 elif [[ $line =~ ^Lines:\ (.*)$ ]]; then
252 size=${BASH_REMATCH[1]}
253 # Create file even if it is zero-length.
254 touch "$path"
255 vecho " $path"
256 elif [[ $line =~ ^Mode:\ (.*)$ ]]; then
257 mode=${BASH_REMATCH[1]}
258 chmod "$mode" "$path"
259 vecho "$mode"
260 elif [[ $line =~ ^Directory:\ (.*)$ ]]; then
261 path=${BASH_REMATCH[1]}
262 mkdir -p "$path"
263 vecho " $path/"
264 elif [[ $line =~ ^SymlinkTo:\ (.*)$ ]]; then
265 ln -s "${BASH_REMATCH[1]}" "$path"
266 vecho " $path -> ${BASH_REMATCH[1]}"
267 elif [[ $line =~ ^# ]]; then
268 # Ignore comments between files
269 continue
270 else
271 echo >&2 "ERROR: Unknown keyword on line $line_no: $line"
272 exit 1
273 fi
274 done < "$ttar_file"
275}
276
277function div {
278 echo "# ttar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -" \
279 "- - - - - -"
280}
281
282function get_mode {
283 local mfile=$1
284 if [ -z "${STAT_OPTION:-}" ]; then
285 if stat -c '%a' "$mfile" >/dev/null 2>&1; then
286 # GNU stat
287 STAT_OPTION='-c'
288 STAT_FORMAT='%a'
289 else
290 # BSD stat
291 STAT_OPTION='-f'
292 # Octal output, user/group/other (omit file type, sticky bit)
293 STAT_FORMAT='%OLp'
294 fi
295 fi
296 stat "${STAT_OPTION}" "${STAT_FORMAT}" "$mfile"
297}
298
299function _create {
300 shopt -s nullglob
301 local mode
302 local eof_without_newline
303 while (( "$#" )); do
304 file=$1
305 if [ -L "$file" ]; then
306 echo "Path: $file"
307 symlinkTo=$(readlink "$file")
308 echo "SymlinkTo: $symlinkTo"
309 vecho " $file -> $symlinkTo"
310 div
311 elif [ -d "$file" ]; then
312 # Strip trailing slash (if there is one)
313 file=${file%/}
314 echo "Directory: $file"
315 mode=$(get_mode "$file")
316 echo "Mode: $mode"
317 vecho "$mode $file/"
318 div
319 # Find all files and dirs, including hidden/dot files
320 for x in "$file/"{*,.[^.]*}; do
321 _create "$x"
322 done
323 elif [ -f "$file" ]; then
324 echo "Path: $file"
325 lines=$(wc -l "$file"|awk '{print $1}')
326 eof_without_newline=0
327 if [[ "$(wc -c "$file"|awk '{print $1}')" -gt 0 ]] && \
328 [[ "$(tail -c 1 "$file" | wc -l)" -eq 0 ]]; then
329 eof_without_newline=1
330 lines=$((lines+1))
331 fi
332 echo "Lines: $lines"
333 # Add backslash in front of EOF
334 # Add backslash in front of NULLBYTE
335 # Replace null byte with NULLBYTE
336 if [ $USE_PYTHON -eq 1 ]; then
337 < "$file" python -c "$PYTHON_CREATE_FILTER"
338 else
339 < "$file" \
340 sed 's/EOF/\\EOF/g;
341 s/NULLBYTE/\\NULLBYTE/g;
342 s/\x0/NULLBYTE/g;
343 '
344 fi
345 if [[ "$eof_without_newline" -eq 1 ]]; then
346 # Finish line with EOF to indicate that the original line did
347 # not end with a linefeed
348 echo "EOF"
349 fi
350 mode=$(get_mode "$file")
351 echo "Mode: $mode"
352 vecho "$mode $file"
353 div
354 else
355 echo >&2 "ERROR: file not found ($file in $(pwd))"
356 exit 2
357 fi
358 shift
359 done
360}
361
362function create {
363 ttar_file=$1
364 shift
365 if [ -z "${1:-}" ]; then
366 echo >&2 "ERROR: missing arguments."
367 echo
368 usage 1
369 fi
370 if [ -e "$ttar_file" ]; then
371 rm "$ttar_file"
372 fi
373 exec > "$ttar_file"
374 echo "# Archive created by ttar $ARG_STRING"
375 _create "$@"
376}
377
378test_environment
379
380if [ -n "${CDIR:-}" ]; then
381 if [[ "$ARCHIVE" != /* ]]; then
382 # Relative path: preserve the archive's location before changing
383 # directory
384 ARCHIVE="$(pwd)/$ARCHIVE"
385 fi
386 cd "$CDIR"
387fi
388
389"$CMD" "$ARCHIVE" "$@"