e107f9648ba2e165aa31d6223e69eec5d8f8812f
[petitboot] / utils / pb-plugin
1 #!/bin/sh
2
3 __dest=/
4 __pb_mount_dir=/var/petitboot/mnt/dev/
5 plugin_abi=1
6 plugin_ext=pb-plugin
7 plugin_meta=pb-plugin.conf
8 plugin_meta_dir=etc/preboot-plugins/
9 plugin_meta_path=$plugin_meta_dir$plugin_meta
10 plugin_wrapper_dir=/var/lib/pb-plugins/bin
11
12 usage()
13 {
14         cat <<EOF
15 Usage: $0 <command>
16
17 Where <command> is one of:
18   install <FILE|URL> - install plugin from FILE/URL
19   scan               - look for available plugins on attached devices
20   create <DIR>       - create a new plugin archive from DIR
21   lint <FILE>        - perform a pre-distribution check on FILE
22 EOF
23 }
24
25 is_url()
26 {
27         local url tmp
28         url=$1
29         tmp=${url#*://}
30         [ "$url" != "$tmp" ]
31 }
32
33 download()
34 {
35         local url file proto
36         url=$1
37         file=$2
38         proto=${url%://*}
39
40         case "$proto" in
41         http)
42                 wget -O - "$url" > $file
43                 ;;
44         ftp)
45                 ncftpget -c "$url" > $file
46                 ;;
47         *)
48                 echo "error: Unsuported protocol $proto" >&2
49                 false
50         esac
51 }
52
53 plugin_info()
54 {
55         local title
56         if [ "$PLUGIN_VENDOR" ]
57         then
58                 title="$PLUGIN_VENDOR: $PLUGIN_NAME"
59         else
60                 title="$PLUGIN_NAME"
61         fi
62
63         echo "$title"
64         echo "  (version $PLUGIN_VERSION)"
65 }
66
67 parse_meta()
68 {
69         local file name value IFS
70
71         file=$1
72
73         IFS='='
74         while read -r name value
75         do
76                 # Ensure we have a sensible variable name
77                 echo "$name" | grep -q '^PLUGIN_[A-Z_]*$' || continue
78
79                 # we know that $name has no quoting/expansion chars, but we
80                 # may need to do some basic surrounding-quote removal for
81                 # $value, without evaluating it
82                 value=$(echo "$value" | sed "s/^\([\"']\)\(.*\)\1\$/\2/g")
83
84                 export $name="$value"
85         done < $file
86 }
87
88 # How the ABI versioning works:
89 #
90 #  - This script has an ABI defined ($plugin_abi)
91 #
92 #  - Plugins have a current ABI number ($PLUGIN_ABI), and a minimum supported
93 #    ABI number ($PLUGIN_ABI_MIN).
94 #
95 #  - A plugin is OK to run if:
96 #    - the plugin's ABI matches the script ABI, or
97 #    - the plugin's minimum ABI is lower than or equal to the script ABI
98 plugin_abi_check()
99 {
100         [ -n "$PLUGIN_ABI" ] &&
101                 ( [ $PLUGIN_ABI -eq $plugin_abi ] ||
102                   [ $PLUGIN_ABI_MIN -le $plugin_abi ] )
103 }
104
105 do_wrap()
106 {
107         local base binary dir
108
109         base=$1
110         binary=$2
111         shift 2
112
113         for dir in etc dev sys proc var
114         do
115                 [ -e "$base/$dir" ] || mkdir -p "$base/$dir"
116         done
117
118         cp /etc/resolv.conf $base/etc
119         mount -o bind /dev $base/dev
120         mount -o bind /sys $base/sys
121         mount -o bind /proc $base/proc
122         mount -o bind /var $base/var
123
124         chroot "$base" "$binary" "$@"
125
126         umount $base/dev
127         umount $base/sys
128         umount $base/proc
129         umount $base/var
130 }
131
132 __create_wrapper()
133 {
134         local base binary wrapper
135
136         base=$1
137         binary=$2
138         wrapper=$plugin_wrapper_dir/$(basename $binary)
139
140         mkdir -p $plugin_wrapper_dir
141
142         cat <<EOF > $wrapper
143 #!/bin/sh
144
145 exec $(realpath $0) __wrap '$base' '$binary' "\$@"
146 EOF
147
148         chmod a+x $wrapper
149 }
150
151 do_install()
152 {
153         local url name file __dest
154
155         url=$1
156
157         if [ -z "$url" ]
158         then
159                 echo "error: install requires a file/URL argument." >&2
160                 exit 1
161         fi
162
163         name=${url##*/}
164
165         if is_url "$url"
166         then
167                 file=$(mktemp)
168                 trap "rm '$file'" EXIT
169                 download "$url" "$file"
170                 if [ $? -ne 0 ]
171                 then
172                         echo "error: failed to download $url" >&2
173                         exit 1
174                 fi
175         else
176                 file=$url
177                 if [ ! -r "$file" ]
178                 then
179                         echo "error: $file doesn't exist or is not readable" >&2
180                         exit 1
181                 fi
182         fi
183
184         echo "File '$name' has the following sha256 checksum:"
185         echo
186         sha256sum "$file" | cut -f1 -d' '
187         echo
188
189         echo "Do you want to install this plugin? (y/N)"
190         read resp
191
192         case $resp in
193         [yY]|[yY][eE][sS])
194                 ;;
195         *)
196                 echo "Cancelled"
197                 exit 0
198                 ;;
199         esac
200
201         __dest=$(mktemp -d)
202         gunzip -c "$file" | ( cd $__dest && cpio -i -d)
203
204         if [ $? -ne 0 ]
205         then
206                 echo "error: Failed to extract archive $url, exiting"
207                 rm -rf $__dest
208                 exit 1
209         fi
210
211         parse_meta $__dest/$plugin_meta_path
212
213         if ! plugin_abi_check
214         then
215                 echo "Plugin at $url is incompatible with this firmware," \
216                         "exiting."
217                 rm -rf $__dest
218                 exit 1
219         fi
220
221         for binary in ${PLUGIN_EXECUTABLES}
222         do
223                 __create_wrapper "$__dest" "$binary"
224         done
225
226         echo "Plugin installed"
227         plugin_info
228 }
229
230 do_scan_mount()
231 {
232         local mnt dev plugin_path __meta_tmp
233         mnt=$1
234         dev=$(basename $mnt)
235
236         for plugin_path in $mnt/*.$plugin_ext
237         do
238                 [ -e "$plugin_path" ] || continue
239
240                 # extract plugin metadata to a temporary directory
241                 __meta_tmp=$(mktemp -d)
242                 [ -d $__meta_tmp ] || continue
243                 gunzip -c "$plugin_path" 2>/dev/null |
244                         (cd $__meta_tmp &&
245                                 cpio -i -d $plugin_meta_path 2>/dev/null)
246                 if ! [ $? = 0 -a -e "$plugin_path" ]
247                 then
248                         rm -rf $__meta_tmp
249                         continue
250                 fi
251
252                 (
253                         parse_meta $__meta_tmp/$plugin_meta_path
254
255                         plugin_abi_check || exit 1
256
257                         printf "Plugin found on %s:\n" $dev
258                         plugin_info
259                         printf "\n"
260                         printf "To run this plugin:\n"
261                         printf "  $0 install $plugin_path\n"
262                         printf "\n"
263                 )
264                 if [ $? = 0 ]
265                 then
266                         found=1
267                 fi
268                 rm -rf $__meta_tmp
269         done
270 }
271
272 do_scan()
273 {
274         local found mnt
275         found=0
276         for mnt in $__pb_mount_dir/*
277         do
278                 do_scan_mount $mnt
279         done
280
281         if [ "$found" = 0 ]
282         then
283                 echo "No plugins found"
284         fi
285 }
286
287 guided_meta()
288 {
289         local vendorname vendorshortname
290         local pluginname pluginnhortname
291         local version date executable
292         local file
293
294         file=$1
295
296 cat <<EOF
297
298 Enter the vendor company / author name. This can contain spaces.
299 (eg. 'Example Corporation')
300 EOF
301         read vendorname
302 cat <<EOF
303
304 Enter the vendor shortname. This should be a single-word abbreviation, in all
305 lower-case. This is only used in internal filenames.
306
307 Typically, a stock-ticker name is used for this (eg 'exco')
308 EOF
309         read vendorshortname
310
311 cat <<EOF
312
313 Enter the descriptive plugin name. This can contain spaces, but should only be
314 a few words in length (eg 'RAID configuration utility')
315 EOF
316         read pluginname
317
318 cat <<EOF
319
320 Enter the plugin shortname. This should not contain spaces, but hyphens are
321 fine (eg 'raid-config'). This is only used in internal filnames.
322 EOF
323         read pluginshortname
324
325
326 cat <<EOF
327
328 Enter the plugin version. This should not contain spaces (eg 1.2):
329 EOF
330         read version
331
332 cat <<EOF
333
334 Enter the full path (within the plugin root) to the plugin executable file(s).
335 These will be exposed as wrapper scripts, to be run from the standard petitboot
336 shell environment (eg, /usr/bin/my-raid-config).
337
338 If multiple executables are provided, separate with a space.
339 EOF
340         read executables
341
342         date=$(date +%Y-%m-%d)
343
344         mkdir -p $(dirname $file)
345
346         cat <<EOF > $file
347 PLUGIN_ABI='$plugin_abi'
348 PLUGIN_ABI_MIN='1'
349 PLUGIN_VENDOR='$vendorname'
350 PLUGIN_VENDOR_ID='$vendorshortname'
351 PLUGIN_NAME='$pluginname'
352 PLUGIN_ID='$pluginshortname'
353 PLUGIN_VERSION='$version'
354 PLUGIN_DATE='$date'
355 PLUGIN_EXECUTABLES='$executables'
356 EOF
357
358 }
359
360 do_create()
361 {
362         local src meta_dir_abs meta_file
363         src=$1
364
365         if [ -z "$src" ]
366         then
367                 echo "error: missing source directory" >&2
368                 usage
369                 exit 1
370         fi
371
372         if [ ! -d "$src" ]
373         then
374                 echo "error: source directory missing" >&2
375                 exit 1
376         fi
377
378         meta_file=$src/$plugin_meta_path
379
380         if [ ! -e $meta_file ]
381         then
382                 echo "No plugin metadata file found. " \
383                         "Would you like to create one? (Y/n)"
384                 read resp
385                 case "$resp" in
386                 [nN]|[nN][oO])
387                         echo "Cancelled, exiting"
388                         exit 1
389                         ;;
390                 esac
391                 guided_meta $meta_file || exit
392         fi
393
394         # Sanity check metadata file
395         parse_meta $meta_file
396
397         errors=0
398         warnings=0
399
400         lint_metadata
401
402         if [ $errors -ne 0 ]
403         then
404                 exit 1
405         fi
406
407         outfile=${PLUGIN_ID}-${PLUGIN_VERSION}.${plugin_ext}
408
409         (
410                 cd $src
411                 find -mindepth 1 | cpio -o -Hnewc -v
412         ) | gzip -c > $outfile
413
414         echo
415         echo "Plugin metadata:"
416         sed -e 's/^/  /' $meta_file
417         echo
418
419         echo "User-visible metadata:"
420         plugin_info | sed -e 's/^/  /'
421
422         echo
423
424 cat <<EOF
425 Plugin created in:
426   $outfile
427
428 Ship this file in the top-level-directory of a USB device or CD to have it
429 automatically discoverable by 'pb-plugin scan'. This file can be re-named,
430 but must retain the .$plugin_ext extension to be discoverable.
431 EOF
432 }
433
434 lint_fatal()
435 {
436         echo "fatal:" "$@"
437         [ -d "$__dest" ] && rm -rf "$__dest"
438         exit 1
439 }
440
441 lint_err()
442 {
443         echo "error:" "$@"
444         errors=$(($errors+1))
445 }
446
447 lint_warn()
448 {
449         echo "warning:" "$@"
450         warnings=$(($warnings+1))
451 }
452
453 lint_metadata()
454 {
455         [ -n "$PLUGIN_ABI" ] ||
456                 lint_err "no PLUGIN_ABI defined in metadata"
457
458         printf '%s' "$PLUGIN_ABI" | grep -q '[^0-9]' &&
459                 lint_err "PLUGIN_ABI has non-numeric characters"
460
461         [ -n "$PLUGIN_ABI_MIN" ] ||
462                 lint_err "no PLUGIN_ABI_MIN defined in metadata"
463
464         printf '%s' "$PLUGIN_ABI_MIN" | grep -q '[^0-9]' &&
465                 lint_err "PLUGIN_ABI_MIN has non-numeric characters"
466
467         [ "$PLUGIN_ABI" = "$plugin_abi" ] ||
468                 lint_warn "PLUGIN_ABI (=$PLUGIN_ABI) is not $plugin_abi"
469
470         [ -n "$PLUGIN_VENDOR" ] ||
471                 lint_err "no PLUGIN_VENDOR defined in metadata"
472
473         [ -n "$PLUGIN_VENDOR_ID" ] ||
474                 lint_err "no PLUGIN_VENDOR_ID defined in metadata"
475
476         printf '%s' "$PLUGIN_VENDOR_ID" | grep -q '[^a-z0-9-]' &&
477                 lint_err "PLUGIN_VENDOR_ID should only contain lowercase" \
478                         "alphanumerics and hyphens"
479
480         [ -n "$PLUGIN_NAME" ] ||
481                 lint_err "no PLUGIN_NAME defined in metadata"
482
483         [ -n "$PLUGIN_ID" ] ||
484                 lint_err "no PLUGIN_ID defined in metadata"
485
486         printf '%s' "$PLUGIN_ID" | grep -q '[^a-z0-9-]' &&
487                 lint_err "PLUGIN_ID should only contain lowercase" \
488                         "alphanumerics and hyphens"
489
490         [ "$PLUGIN_VERSION" ] ||
491                 lint_err "no PLUGIN_VERSION defined in metadata"
492
493         [ -n "$PLUGIN_DATE" ] ||
494                 lint_err "no PLUGIN_DATE defined in metadata"
495
496         [ -n "$PLUGIN_EXECUTABLES" ] ||
497                 lint_err "no PLUGIN_EXECUTABLES defined in metadata"
498 }
499
500 do_lint()
501 {
502         local plugin_file errors warnings __dest executable dir
503
504         plugin_file=$1
505         errors=0
506         warnings=0
507         __dest=
508
509         [ "${plugin_file##*.}" = $plugin_ext ] ||
510                 lint_err "Plugin file does not end with $plugin_ext"
511
512         gunzip -c "$plugin_file" > /dev/null 2>&1 ||
513                 lint_fatal "Plugin can't be gunzipped"
514
515         gunzip -c "$plugin_file" 2>/dev/null | cpio -t >/dev/null 2>&1 ||
516                 lint_fatal "Plugin can't be cpioed"
517
518         __dest=$(mktemp -d)
519         gunzip -c "$plugin_file" | ( cd $__dest && cpio -i -d 2>/dev/null)
520
521         [ -e "$__dest/$plugin_meta_path" ] ||
522                 lint_fatal "No metadata file present (expecting" \
523                         "$plugin_meta_path)"
524
525         parse_meta "$__dest/$plugin_meta_path"
526         lint_metadata
527
528         for executable in ${PLUGIN_EXECUTABLES}
529         do
530                 exec_path="$__dest/$executable"
531                 [ -e "$exec_path" ] || {
532                         lint_err "PLUGIN_EXECUTABLES item $executable" \
533                                 "doesn't exist"
534                         continue
535                 }
536
537                 [ -x "$exec_path" ] ||
538                         lint_err "PLUGIN_EXECUTABLES item $executable" \
539                                 "isn't executable"
540         done
541
542         for dir in dev sys proc var
543         do
544                 [ -e "$__dest/$dir" ] || continue
545
546                 [ -d "$__dest/$dir" ] ||
547                         lint_err "/$dir exists, but isn't a directory"
548
549                 [ "$(find $__dest/$dir -mindepth 1)" ] &&
550                         lint_warn "/$dir contains files/directories," \
551                                 "these will be lost during chroot setup"
552         done
553
554         printf '%s: %d errors, %d warnings\n' $plugin_file $errors $warnings
555         rm -rf $__dest
556         [ $errors = 0 ]
557 }
558
559 test_http_download()
560 {
561         local tmp ref
562
563         tmp=$(mktemp -p $test_tmpdir)
564         ref=$(mktemp -p $test_tmpdir)
565
566         echo $RANDOM > $ref
567
568         wget()
569         {
570                 cat $ref
571         }
572
573         download http://example.com/test $tmp
574         cmp -s "$ref" "$tmp"
575 }
576
577 test_ftp_download()
578 {
579         local tmp ref
580
581         tmp=$(mktemp -p $test_tmpdir)
582         ref=$(mktemp -p $test_tmpdir)
583
584         echo $RANDOM > $ref
585
586         ncftpget()
587         {
588                 cat $ref
589         }
590
591         download ftp://example.com/test $tmp
592         cmp -s "$ref" "$tmp"
593 }
594
595 test_abi_check()
596 {
597         (
598                 plugin_abi=$1
599                 PLUGIN_ABI=$2
600                 PLUGIN_ABI_MIN=$3
601                 plugin_abi_check
602         )
603 }
604
605 test_scan()
606 {
607         __pb_mount_dir="$test_tmpdir/mnt"
608         mnt_dir="$__pb_mount_dir/sda"
609         mkdir -p $mnt_dir/$plugin_meta_dir
610         (
611                 echo "PLUGIN_ABI=$plugin_abi"
612                 echo "PLUGIN_NAME=test"
613                 echo "PLUGIN_VERSION=1"
614                 echo "PLUGIN_EXECUTABLES=/bin/sh"
615         ) > $mnt_dir/$plugin_meta_path
616         (
617                 cd $mnt_dir;
618                 find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
619         ) | gzip -c > $mnt_dir/test.$plugin_ext
620
621         do_scan | grep -q 'test'
622 }
623
624 test_scan_nogzip()
625 {
626         __pb_mount_dir="$test_tmpdir/mnt"
627         mnt_dir="$__pb_mount_dir/sda"
628         stderr_file="$test_tmpdir/stderr"
629
630         mkdir -p $mnt_dir
631         echo "invalid" > $mnt_dir/nogzip.$plugin_ext
632
633         do_scan 2>$stderr_file | grep -q 'No plugins'
634
635         [ $? = 0 ] || return 1
636
637         if [ -s "$stderr_file" ]
638         then
639                 echo "Scan with invalid (non-gzip) file produced error output" \
640                         >&2
641                 cat "$stderr_file"
642                 return 1
643         fi
644         true
645 }
646
647 test_scan_nocpio()
648 {
649         __pb_mount_dir="$test_tmpdir/mnt"
650         mnt_dir="$__pb_mount_dir/sda"
651         stderr_file="$test_tmpdir/stderr"
652
653         mkdir -p $mnt_dir
654         echo "invalid" | gzip -c > $mnt_dir/nogzip.$plugin_ext
655
656         do_scan 2>$stderr_file | grep -q 'No plugins'
657
658         [ $? = 0 ] || return 1
659
660         if [ -s "$stderr_file" ]
661         then
662                 echo "Scan with invalid (non-cpio) file produced error output" \
663                         >&2
664                 cat "$stderr_file"
665                 return 1
666         fi
667         true
668 }
669
670 test_scan_multiple()
671 {
672         __pb_mount_dir="$test_tmpdir/mnt"
673         mnt_dir="$__pb_mount_dir/sda"
674         outfile=$test_tmpdir/scan.out
675
676         for i in 1 2
677         do
678                 mkdir -p $mnt_dir/$plugin_meta_dir
679                 (
680                         echo "PLUGIN_ABI=$plugin_abi"
681                         echo "PLUGIN_NAME=test-$i"
682                         echo "PLUGIN_VERSION=1"
683                         echo "PLUGIN_EXECUTABLES=/bin/sh"
684                 ) > $mnt_dir/$plugin_meta_path
685                 (
686                         cd $mnt_dir;
687                         find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
688                 ) | gzip -c > $mnt_dir/test-${i}.$plugin_ext
689                 rm -rf $mnt_dir/$plugin_meta_dir
690         done
691
692         do_scan >$outfile
693
694         grep -q 'test-1' $outfile && grep -q 'test-2' $outfile
695 }
696
697 test_scan_wrongabi()
698 {
699         __pb_mount_dir="$test_tmpdir/mnt"
700         mnt_dir="$__pb_mount_dir/sda"
701         mkdir -p $mnt_dir/$plugin_meta_dir
702         (
703                 echo "PLUGIN_ABI=$(($plugin_abi + 1))"
704                 echo "PLUGIN_ABI_MIN=$(($plugin_abi + 1))"
705                 echo "PLUGIN_NAME=test"
706                 echo "PLUGIN_VERSION=1"
707                 echo "PLUGIN_EXECUTABLES=/bin/sh"
708         ) > $mnt_dir/$plugin_meta_path
709         (
710                 cd $mnt_dir;
711                 find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
712         ) | gzip -c > $mnt_dir/test.$plugin_ext
713
714         do_scan | grep -q 'No plugins'
715 }
716
717 test_empty_scan()
718 {
719         __pb_mount_dir="$test_tmpdir/mnt"
720         mkdir -p $__pb_mount_dir
721         do_scan | grep -q "No plugins"
722 }
723
724 test_setup()
725 {
726         n=$(($n+1))
727
728         test_tmpdir="$tests_tmpdir/$n"
729         mkdir "$test_tmpdir"
730 }
731
732 test_teardown()
733 {
734         true
735 }
736
737 test_failed=0
738 do_test()
739 {
740         local tstr op
741
742         tstr="$@"
743         op=-eq
744
745         if [ "x$1" = "x!" ]
746         then
747                 op=-ne
748                 shift
749         fi
750
751         test_setup
752         ( $@ )
753         local rc=$?
754         test_teardown
755
756         if [ $rc $op 0 ]
757         then
758                 echo PASS: "$tstr"
759         else
760                 echo FAIL: "$tstr"
761                 test_failed=1
762                 false
763         fi
764 }
765
766 do_tests()
767 {
768         local tests_tmpdir n
769
770         tests_tmpdir=$(mktemp -d)
771         n=0
772
773         do_test ! is_url "/test"
774         do_test ! is_url "./test"
775         do_test ! is_url "../test"
776         do_test ! is_url "test"
777         do_test is_url "http://example.com/path"
778         do_test is_url "git+ssh://example.com/path"
779         do_test test_http_download
780         do_test test_ftp_download
781         do_test ! test_abi_check
782         do_test ! test_abi_check 1
783         do_test test_abi_check 1 1
784         do_test test_abi_check 1 1 1
785         do_test test_abi_check 1 2 0
786         do_test test_abi_check 1 2 1
787         do_test ! test_abi_check 1 2 2
788         do_test test_scan
789         do_test test_scan_nogzip
790         do_test test_scan_nocpio
791         do_test test_scan_multiple
792         do_test test_scan_wrongabi
793         do_test test_empty_scan
794
795         if [ $test_failed = 0 ]
796         then
797                 echo "$n tests passed"
798         else
799                 echo "Tests failed"
800         fi
801         rm -rf "$tests_tmpdir"
802
803         [ $test_failed = 0 ]
804 }
805
806 case "$1" in
807 install)
808         shift
809         do_install $@
810         ;;
811 scan)
812         shift
813         do_scan $@
814         ;;
815 create)
816         shift
817         do_create $@
818         ;;
819 lint)
820         shift
821         do_lint $@
822         ;;
823 __wrap)
824         shift
825         do_wrap $@
826         ;;
827 __test)
828         shift
829         do_tests $@
830         ;;
831 "")
832         echo "error: Missing command" >&2
833         usage
834         exit 1
835         ;;
836 *)
837         echo "Invalid command: $1" >&2
838         usage
839         exit 1
840 esac
841
842