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