You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

414 lines
12 KiB

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